Compare commits

..

24 commits

Author SHA1 Message Date
bfc2592925
chore: ruff uses 88 by default 2023-03-15 16:08:45 -05:00
8493916ff8
fix: use proper XDG directory as default cache 2023-03-15 15:16:38 -05:00
0d0c66d090
build: add hook to set version in viv.py 2023-03-15 15:16:12 -05:00
1e74ebf413
fix: make repo-based install version dependent by default 2023-03-15 15:01:05 -05:00
8fdb1817a7
fix: use echo/error not print 2023-03-15 15:00:35 -05:00
ccf8e0ec61
refactor!: activate -> use 2023-03-15 13:16:09 -05:00
992d039285
refactor: make hash generation match standalone method 2023-03-14 15:14:47 -05:00
d367571957
fix: use stderr where appropriate to allow piping 2023-03-14 15:14:24 -05:00
cb27f9a8d4
refactor: continue to refine standalone mode 2023-03-14 12:35:00 -05:00
9c2581fafd
refactor: don't shadow hash function 2023-03-13 09:07:18 -05:00
b9100ca2a1
docs: add more full fledged documentation 2023-03-09 14:58:01 -06:00
e548487918
refactor: abandon python 3.7 to maximize code-golf in standalone mode 2023-03-09 12:49:55 -06:00
834cd449bd
docs: update README example 2023-03-08 15:10:11 -06:00
f646278f8b
chore: swap todo's 2023-03-08 12:27:24 -06:00
621f5a7aca
docs: start actually documenting some things 2023-03-08 04:06:04 -06:00
03c5b06a8a
docs: add list-info-remove to usage page 2023-03-08 03:37:41 -06:00
28e57c99b4
chore: do some todos 2023-03-08 03:36:08 -06:00
1f11967f3f
build: swap ruff for flake8 2023-03-08 03:33:04 -06:00
ff110f0829
docs: make more vhs tapes 2023-03-08 03:28:07 -06:00
9b5296036d
docs: improve timing on freeze.tape 2023-03-08 03:06:55 -06:00
54c9dbdf4b
docs: add textual/stopwatch example 2023-03-08 02:49:53 -06:00
c797347aa6
feat: add --standalone generator to viv freeze 2023-03-08 02:31:12 -06:00
10599dcd01
refactor: use id instead of build_id 2023-03-07 14:51:51 -06:00
0d583584dc
build: add clean recipe 2023-03-07 13:00:48 -06:00
23 changed files with 512 additions and 94 deletions

View file

@ -16,7 +16,14 @@ repos:
hooks:
- id: black
language_version: python
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.245'
hooks:
- id: flake8
- id: ruff
- repo: local
hooks:
- id: set-version
language: script
name: sets __version__ in viv.py
entry: ./scripts/bump-dev.sh
files: viv.py$

View file

@ -10,6 +10,7 @@ types: ## run mypy
bump-version: ## update version and tag commit
@echo "bumping to version => $(VERSION)"
@sed -i 's/__version__ = ".*"/__version__ = "$(VERSION)"/g' src/viv/viv.py
@sed 's/--branch .* g/--branch $(VERSION) g/g' README.md
@git add src/viv/viv.py && git commit -m "chore: bump version"
@git tag v$(VERSION)
@ -22,11 +23,16 @@ install: ## symlink to $PREFIX
uninstall: ## delete $(PREFIX)/viv
rm $(PREFIX)/viv
docs: docs/demo.gif docs/freeze.gif ## generate usage examples
TAPES = demo freeze list-info-remove
GIFS := $(foreach n, $(TAPES), docs/$(n).gif)
docs: $(GIFS) ## generate usage examples
docs/%.gif: docs/%.tape
viv rm $$(viv l -q)
vhs < $<
cd docs; vhs < $*.tape
clean: ## remove build artifacts
rm -rf {build,dist}
EXAMPLES = cli.py sys_path.py exe_specific.py frozen_import.py named_env.py scrape.py
generate-example-vivens: ##

121
README.md
View file

@ -11,14 +11,34 @@
</div>
<br />
See [usage](https://github.com/daylinmorgan/viv/blob/main/docs/usage.md) for more demo gifs.
---
Python is a great choice to quickly prototype or accomplish small tasks in scripts.
However, leveraging it's vast ecosystem can be tedious for one-off or rarely used scripts.
This is were `viv` comes in handy.
`Viv` is a standalone dependency-free `venv` creator.
It is meant to be invoked in any script that has third-party dependencies,
prior to loading of any of the external modules.
These `venvs` can be identified by name or by their specification.
In any case they will be re-used across scripts (and generated on-demand, if needed).
**Importantly**, `viv` will remove your user site directory (`python -m 'import site;print(site.USER_SITE)'`),
to ensure the script isn't using anything outside the standard library and the `viv`-managed `venv`.
## Setup
### Manual (Recommended)
Start by cloning the repo and symlinking the script for access to the CLI.
By default it will symlink `./src/viv/viv.py` to `~/bin/viv`.
You can set `PREFIX` to symlink to a different location.
```sh
git clone git@github.com:daylinmorgan/viv.git ~/.viv
git clone --depth 1 --branch v22.12a3 git@github.com:daylinmorgan/viv.git ~/.viv
cd ~/.viv
make install # or PREFIX=~/.local/bin make install
```
@ -29,20 +49,107 @@ Place this directory on the python path in your rc file.
export PYTHONPATH="$PYTHONPATH:$HOME/.viv/src"
```
Then in any python script with external dependencies you can add this line.
Advanced users may recognize that principally,
the module just needs to be recognized at run time
and the single script at `./src/viv/viv.py` can be invoked directly for the CLI.
How you accomplish these options is ultimately up to you but the above instructions can get you started.
```python
__import__("viv").activate("click")
### Pypi (Not Recommended)
```sh
pip install viv
```
Why is this *not recommended*? Mainly, because `viv` is all about hacking your `sys.path`.
Placing it in it's own virtual environment or installing in a user site directory may complicate this endeavor.
## Usage
To remove all viv venvs:
In any python script with external dependencies you can add this line,
to automate `vivenv` creation and installation of dependencies.
```python
__import__("viv").use("click")
```
To remove all `vivenvs`:
```sh
viv remove $(viv list -q)
```
# Standalone Viv
*Requires* `python>=3.8`
Supposing you want to increase the portability of your script while still employing `viv`.
The below function can be freely pasted at the top of your scripts and requires
no modification of your PYTHONPATH or import of additional modules (including downloading/installing `viv`).
It can be auto-generated with for example: `viv freeze <spec> --standalone`.
The only part necessary to modify if copied verbatim from below is the call to `_viv_use`.
output of `viv freeze rich --standalone`:
```python
# <<<<< auto-generated by daylinmorgan/viv (v.22.12a3)
# fmt: off
def _viv_use(*pkgs: str, track_exe: bool = False, name: str = "") -> None: # noqa
i,s,m,e,spec=__import__,str,map,lambda x: True if x else False,[*pkgs] # noqa
if not {*m(type,pkgs)}=={s}: raise ValueError(f"spec: {pkgs} is invalid") # noqa
ge,sys,P,ew=i("os").getenv,i("sys"),i("pathlib").Path,i("sys").stderr.write # noqa
(cache:=(P(ge("XDG_CACHE_HOME",P.home()/".cache"))/"viv"/"venvs")).mkdir(parents=True,exist_ok=True) # noqa
((sha256:=i("hashlib").sha256()).update((s(spec)+ # noqa
(((exe:=("N/A",s(P(i("sys").executable).resolve()))[e(track_exe)])))).encode())) # noqa
if (env:=cache/(name if name else (_id:=sha256.hexdigest()))) not in cache.glob("*/") or ge("VIV_FORCE"): # noqa
v=e(ge("VIV_VERBOSE"));ew(f"generating new vivenv -> {env.name}\n") # noqa
i("venv").EnvBuilder(with_pip=True,clear=True).create(env) # noqa
with (env/"pip.conf").open("w") as f:f.write("[global]\ndisable-pip-version-check=true") # noqa
if (p:=i("subprocess").run([env/"bin"/"pip","install","--force-reinstall",*spec],text=True, # noqa
stdout=(-1,None)[v],stderr=(-2,None)[v])).returncode!=0: # noqa
if env.is_dir():i("shutil").rmtree(env) # noqa
ew(f"pip had non zero exit ({p.returncode})\n{p.stdout}\n");sys.exit(p.returncode) # noqa
with (env/"viv-info.json").open("w") as f: # noqa
i("json").dump({"created":s(i("datetime").datetime.today()),"id":_id,"spec":spec,"exe":exe},f) # noqa
sys.path = [p for p in (*sys.path,s(*(env/"lib").glob("py*/si*"))) if p!=i("site").USER_SITE] # noqa
_viv_use("markdown-it-py==2.2.0", "mdurl==0.1.2", "Pygments==2.14.0", "rich==13.3.2") # noqa
# fmt: on
# >>>>> code golfed with <3
```
## Alternatives
- `pipx`
- `pip-run`
### [pip-run](https://github.com/jaraco/pip-run)
```sh
pip-run (10.0.5)
├── autocommand (2.2.2)
├── jaraco-context (4.3.0)
├── jaraco-functools (3.6.0)
│ └── more-itertools (9.1.0)
├── jaraco-text (3.11.1)
│ ├── autocommand (2.2.2)
│ ├── inflect (6.0.2)
│ │ └── pydantic>=1.9.1 (1.10.5)
│ │ └── typing-extensions>=4.2.0 (4.5.0)
│ ├── jaraco-context>=4.1 (4.3.0)
│ ├── jaraco-functools (3.6.0)
│ │ └── more-itertools (9.1.0)
│ └── more-itertools (9.1.0)
├── more-itertools>=8.3 (9.1.0)
├── packaging (23.0)
├── path>=15.1 (16.6.0)
├── pip>=19.3 (23.0.1)
└── platformdirs (3.1.0)
```
### [pipx](https://github.com/pypa/pipx/)
```sh
pipx (1.1.0)
├── argcomplete>=1.9.4 (2.1.1)
├── packaging>=20.0 (23.0)
└── userpath>=1.6.0 (1.8.0)
└── click (8.1.3)
```

11
docs/demo.py Normal file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env python3
__import__("viv").use("pyfiglet==0.8.post1") # noqa
from pyfiglet import Figlet
if __name__ == "__main__":
f = Figlet(font="slant")
figtxt = f.renderText("viv").splitlines()
figtxt[-2] += " isn't venv!"
print("\n".join(figtxt))

View file

@ -1,12 +1,12 @@
Set Height 750
Set Width 1500
Set Theme "Catppuccin Mocha"
Output ./docs/demo.gif
Output ./demo.gif
Type "viv list"
Enter
Sleep 2s
Type "python examples/cli.py --help"
Type "python ../examples/cli.py --help"
Enter
Sleep 10s
Type "viv list"
@ -15,9 +15,9 @@ Sleep 3s
Type "viv info 841"
Enter
Sleep 3s
Type "python examples/cli.py hello 'prospective viv user!'"
Type "python ../examples/cli.py hello 'prospective viv user!'"
Enter
Sleep 2s
Type "python examples/cli.py goodbye 'prospective viv user!'"
Type "python ../examples/cli.py goodbye 'prospective viv user!'"
Enter
Sleep 2s

View file

@ -1,20 +1,23 @@
Set Height 750
Set Width 1500
Set Theme "Catppuccin Mocha"
Output ./docs/freeze.gif
Output ./freeze.gif
Type "viv freeze --help"
Enter
Sleep 2s
Type "viv list"
Enter
Sleep 2s
Type "viv freeze requests bs4"
Enter
Sleep 5s
Sleep 7s
Type "viv list"
Enter
Sleep 3s
Type "viv freeze requests bs4 --keep --path relative"
Enter
Sleep 5s
Sleep 7s
Type "viv list"
Enter
Sleep 5s

View file

@ -0,0 +1,30 @@
Set Height 750
Set Width 1500
Set Theme "Catppuccin Mocha"
Output ./list-info-remove.gif
Type "cat demo.py"
Enter
Sleep 500ms
Type "python demo.py"
Enter
Sleep 3s
Type "viv list -h"
Enter
Sleep 500ms
Type "viv list -q"
Enter
Sleep 1s
Type "viv info -h"
Enter
Sleep 500ms
Type "viv info "
Sleep 1s
Type "f8"
Enter
Sleep 1s
Type "viv remove -h"
Enter
Type "viv rm f8"
Enter
Sleep 2s

View file

@ -2,6 +2,7 @@
*NOTE*: these demo gif's are a work in progress. If you'd like to see them you can run `vhs` locally with `make docs`.
<div align="center">
# Demo
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/demo.gif" alt="demo" width=600 >
@ -9,3 +10,9 @@
# Freeze
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/freeze.gif" alt="demo" width=600 >
# List | Info | Remove
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/list-info-remove.gif" alt="demo" width=600 >
</div>

View file

@ -4,7 +4,7 @@ It can be convenient to quickly generate a cli for a short script.
Or to add simple visualization of data using the wonderful rich library.
"""
__import__("viv").activate("typer", "rich-click") # noqa
__import__("viv").use("typer", "rich-click") # noqa
import typer

View file

@ -7,7 +7,7 @@ it will generate a new vivenv.
It may be important to require a exe specificty if you are frequently running
different version of pythons and rely on c extension modules as in numpy.
"""
__import__("viv").activate("numpy", "plotext", track_exe=True) # noqa
__import__("viv").use("numpy", "plotext", track_exe=True) # noqa
import numpy as np
import plotext as plt

View file

@ -6,7 +6,7 @@ This import statement was generated using
Using viv freeze ensures future runs of this
script will use the same essential environment
"""
__import__("viv").activate(
__import__("viv").use(
"numpy==1.24.0",
"pandas==1.5.2",
"python-dateutil==2.8.2",

View file

@ -7,7 +7,7 @@ Meaning that it will save it within the viv cache not using a hash.
*This environment could then be reused by specifying the name*
"""
__import__("viv").activate("rich", name="rich-env")
__import__("viv").use("rich", name="rich-env")
from rich.console import Console
from rich.markdown import Markdown

View file

@ -6,7 +6,7 @@ modified from:
https://medium.com/analytics-vidhya/a-super-easy-python-script-for-web-scraping-that-anybody-can-use-d3bd6ab86c89
"""
__import__("viv").activate("requests", "bs4", "rich") # noqa
__import__("viv").use("requests", "bs4", "rich") # noqa
import requests
from bs4 import BeautifulSoup

38
examples/standalone.py Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""
Example using the output of `viv freeze pyfiglet --standalone`.
With this function it's not necessary for
`viv` to exist anywhere on the system.
"""
# <<<<< auto-generated by daylinmorgan/viv (v.22.12a3)
# fmt: off
def _viv_use(*pkgs: str, track_exe: bool = False, name: str = "") -> None: # noqa
i,s,m,e,spec=__import__,str,map,lambda x: True if x else False,[*pkgs] # noqa
if not {*m(type,pkgs)}=={s}: raise ValueError(f"spec: {pkgs} is invalid") # noqa
ge,sys,P,ew=i("os").getenv,i("sys"),i("pathlib").Path,i("sys").stderr.write # noqa
(cache:=(P(ge("XDG_CACHE_HOME",P.home()/".cache"))/"viv"/"venvs")).mkdir(parents=True,exist_ok=True) # noqa
((sha256:=i("hashlib").sha256()).update((s(spec)+ # noqa
(((exe:=("N/A",s(P(i("sys").executable).resolve()))[e(track_exe)])))).encode())) # noqa
if (env:=cache/(name if name else (_id:=sha256.hexdigest()))) not in cache.glob("*/") or ge("VIV_FORCE"): # noqa
v=e(ge("VIV_VERBOSE"));ew(f"generating new vivenv -> {env.name}\n") # noqa
i("venv").EnvBuilder(with_pip=True,clear=True).create(env) # noqa
with (env/"pip.conf").open("w") as f:f.write("[global]\ndisable-pip-version-check=true") # noqa
if (p:=i("subprocess").run([env/"bin"/"pip","install","--force-reinstall",*spec],text=True, # noqa
stdout=(-1,None)[v],stderr=(-2,None)[v])).returncode!=0: # noqa
if env.is_dir():i("shutil").rmtree(env) # noqa
ew(f"pip had non zero exit ({p.returncode})\n{p.stdout}\n");sys.exit(p.returncode) # noqa
with (env/"viv-info.json").open("w") as f: # noqa
i("json").dump({"created":s(i("datetime").datetime.today()),"id":_id,"spec":spec,"exe":exe},f) # noqa
sys.path = [p for p in (*sys.path,s(*(env/"lib").glob("py*/si*"))) if p!=i("site").USER_SITE] # noqa
_viv_use("pyfiglet==0.8.post1") # noqa
# fmt: on
# >>>>> code golfed with <3
from pyfiglet import Figlet
if __name__ == "__main__":
f = Figlet(font="slant")
figtxt = f.renderText("viv").splitlines()
figtxt[-2] += " isn't venv!"
print("\n".join(figtxt))

141
examples/stopwatch.py Normal file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""A tui stopwatch built w/textual adapted from their tutorial:
https://github.com/Textualize/textual/tree/main/docs/examples/tutorial
"""
__import__("viv").use("textual")
from time import monotonic
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Footer, Header, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = reactive(monotonic)
time = reactive(0.0)
total = reactive(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
def update_time(self) -> None:
"""Method to update time to current."""
self.time = self.total + (monotonic() - self.start_time)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
def start(self) -> None:
"""Method to start (or resume) time updating."""
self.start_time = monotonic()
self.update_timer.resume()
def stop(self):
"""Method to stop the time display updating."""
self.update_timer.pause()
self.total += monotonic() - self.start_time
self.time = self.total
def reset(self):
"""Method to reset the time display to zero."""
self.total = 0
self.time = 0
class Stopwatch(Static):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
button_id = event.button.id
time_display = self.query_one(TimeDisplay)
if button_id == "start":
time_display.start()
self.add_class("started")
elif button_id == "stop":
time_display.stop()
self.remove_class("started")
elif button_id == "reset":
time_display.reset()
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay()
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
# CSS_PATH = "stopwatch.css"
DEFAULT_CSS = """
Stopwatch {
layout: horizontal;
background: $boost;
height: 5;
min-width: 50;
margin: 1;
padding: 1;
}
TimeDisplay {
content-align: center middle;
text-opacity: 60%;
height: 3;
}
Button { width: 16; }
#start { dock: left; }
#stop { dock: left; display: none; }
#reset { dock: right; }
.started {
text-style: bold;
background: $success;
color: $text;
}
.started TimeDisplay { text-opacity: 100%; }
.started #start { display: none }
.started #stop { display: block }
.started #reset { visibility: hidden }"""
BINDINGS = [
("d", "toggle_dark", "Toggle dark mode"),
("a", "add_stopwatch", "Add"),
("r", "remove_stopwatch", "Remove"),
]
def compose(self) -> ComposeResult:
"""Called to add widgets to the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
def action_add_stopwatch(self) -> None:
"""An action to add a timer."""
new_stopwatch = Stopwatch()
self.query_one("#timers").mount(new_stopwatch)
new_stopwatch.scroll_visible()
def action_remove_stopwatch(self) -> None:
"""Called to remove a timer."""
timers = self.query("Stopwatch")
if timers:
timers.last().remove()
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
if __name__ == "__main__":
app = StopwatchApp()
app.run()

View file

@ -7,7 +7,7 @@ Embed the viv.py on the sys.path at runtime rather than using PYTHONPATH
__import__("sys").path.append(
__import__("os").path.expanduser("~/.viv/src")
) # noqa # isort: off
__import__("viv").activate("pyfiglet") # noqa # isort: off
__import__("viv").use("pyfiglet") # noqa # isort: off
import sys

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python3
# ints are not allowed
__import__("viv").activate(5)
__import__("viv").use(5)

View file

@ -27,3 +27,11 @@ dev = [
"pre-commit>=3",
"mypy>=0.991"
]
[tool.ruff]
ignore = ["E402"]
[tool.mypy]
warn_return_any = true
check_untyped_defs = true
warn_unused_configs = true

5
scripts/bump-dev.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
TAG=$(git describe --tags --always --dirty=-dev)
VERSION="${TAG#v}"
sed -i "s/__version__ = \".*\"/__version__ = \"$VERSION\"/g" src/viv/viv.py
git add src/viv/viv.py

View file

@ -1,8 +0,0 @@
[flake8]
max-line-length = 88
ignore = E402, W503
[mypy]
warn_return_any = True
check_untyped_defs = True
warn_unused_configs = True

View file

@ -1 +1 @@
from .viv import activate # noqa
from .viv import use # noqa

View file

@ -3,7 +3,7 @@
viv -h
OR
__import__("viv").activate("requests", "bs4")
__import__("viv").use("requests", "bs4")
"""
import hashlib
@ -30,7 +30,7 @@ from pathlib import Path
from textwrap import dedent, wrap
from typing import Dict, List, Tuple
__version__ = "22.12a3"
__version__ = "22.12a3-35-g0d0c66d-dev"
@dataclass
@ -38,9 +38,7 @@ class Config:
"""viv config manager"""
venvcache: Path = (
Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".local" / "cache"))
/ "viv"
/ "venvs"
Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "viv" / "venvs"
)
def __post_init__(self):
@ -67,22 +65,22 @@ class Spinner:
def write_next(self):
with self._screen_lock:
if not self.spinner_visible:
sys.stdout.write(next(self.spinner))
sys.stderr.write(next(self.spinner))
self.spinner_visible = True
sys.stdout.flush()
sys.stderr.flush()
def remove_spinner(self, cleanup=False):
with self._screen_lock:
if self.spinner_visible:
sys.stdout.write("\b\b\b")
sys.stderr.write("\b\b\b")
# sys.stdout.write("\b")
self.spinner_visible = False
if cleanup:
sys.stdout.write(" ") # overwrite spinner with blank
sys.stderr.write(" ") # overwrite spinner with blank
# sys.stdout.write("\r") # move to next line
# move back then delete the line
sys.stdout.write("\r\033[K")
sys.stdout.flush()
sys.stderr.write("\r\033[K")
sys.stderr.flush()
def spinner_task(self):
while self.busy:
@ -91,18 +89,18 @@ class Spinner:
self.remove_spinner()
def __enter__(self):
if sys.stdout.isatty():
if sys.stderr.isatty():
self._screen_lock = threading.Lock()
self.busy = True
self.thread = threading.Thread(target=self.spinner_task)
self.thread.start()
def __exit__(self, exc_type, exc_val, exc_traceback): # noqa
if sys.stdout.isatty():
if sys.stderr.isatty():
self.busy = False
self.remove_spinner(cleanup=True)
else:
sys.stdout.write("\r")
sys.stderr.write("\r")
BOX: Dict[str, str] = {
@ -136,7 +134,7 @@ class Ansi:
metavar: str = "\033[33m" # normal yellow
def __post_init__(self):
if os.getenv("NO_COLOR") or not sys.stdout.isatty():
if os.getenv("NO_COLOR") or not sys.stderr.isatty():
for attr in self.__dict__:
setattr(self, attr, "")
@ -196,7 +194,6 @@ class Ansi:
return sizes
def _make_row(self, row) -> str:
return f" {BOX['v']} " + f" {BOX['sep']} ".join(row) + f" {BOX['v']}"
def _sanitize_row(self, sizes: List[int], row: Tuple[str]) -> Tuple[Tuple[str]]:
@ -249,9 +246,9 @@ class Ansi:
)
)
sys.stdout.write(f" {BOX['tl']}{BOX['h']*(sum(sizes)+5)}{BOX['tr']}\n")
sys.stdout.write("\n".join(table_rows) + "\n")
sys.stdout.write(f" {BOX['bl']}{BOX['h']*(sum(sizes)+5)}{BOX['br']}\n")
sys.stderr.write(f" {BOX['tl']}{BOX['h']*(sum(sizes)+5)}{BOX['tr']}\n")
sys.stderr.write("\n".join(table_rows) + "\n")
sys.stderr.write(f" {BOX['bl']}{BOX['h']*(sum(sizes)+5)}{BOX['br']}\n")
a = Ansi()
@ -269,12 +266,12 @@ def warn(msg):
echo(f"{a.yellow}warn:{a.end} {msg}", style="yellow")
def echo(msg: str, style="magenta", newline=True) -> None:
def echo(msg: str, style="magenta", newline=True, fd=sys.stderr) -> None:
"""output general message to stdout"""
output = f"{a.cyan}Viv{a.end}{a.__dict__[style]}::{a.end} {msg}"
if newline:
output += "\n"
sys.stdout.write(output)
fd.write(output)
def run(
@ -334,14 +331,15 @@ def get_hash(spec: Tuple[str, ...] | List[str], track_exe: bool = False) -> str:
Returns:
sha256 representation of dependencies for vivenv
"""
pkg_hash = hashlib.sha256()
pkg_hash.update(str(spec).encode())
# generate unique venvs for unique python exe's
if track_exe:
pkg_hash.update(str(Path(sys.executable).resolve()).encode())
sha256 = hashlib.sha256()
sha256.update(
(
str(spec) + (str(Path(sys.executable).resolve()) if track_exe else "N/A")
).encode()
)
return pkg_hash.hexdigest()
return sha256.hexdigest()
class ViVenv:
@ -349,14 +347,14 @@ class ViVenv:
self,
spec: List[str],
track_exe: bool = False,
build_id: str | None = None,
id: str | None = None,
name: str = "",
path: Path | None = None,
) -> None:
self.spec = spec
self.exe = str(Path(sys.executable).resolve()) if track_exe else "N/A"
self.build_id = build_id if build_id else get_hash(spec, track_exe)
self.name = name if name else self.build_id
self.id = id if id else get_hash(spec, track_exe)
self.name = name if name else self.id
self.path = path if path else c.venvcache / self.name
@classmethod
@ -372,15 +370,12 @@ class ViVenv:
with (c.venvcache / name / "viv-info.json").open("r") as f:
venvconfig = json.load(f)
vivenv = cls(
name=name, spec=venvconfig["spec"], build_id=venvconfig["build_id"]
)
vivenv = cls(name=name, spec=venvconfig["spec"], id=venvconfig["id"])
vivenv.exe = venvconfig["exe"]
return vivenv
def create(self) -> None:
echo(f"new unique vivenv -> {self.name}")
with Spinner("creating vivenv"):
builder = venv.EnvBuilder(with_pip=True, clear=True)
@ -391,7 +386,6 @@ class ViVenv:
f.write("[global]\ndisable-pip-version-check = true")
def install_pkgs(self):
cmd: List[str | Path] = [
self.path / "bin" / "pip",
"install",
@ -406,12 +400,11 @@ class ViVenv:
)
def dump_info(self, write=False):
# TODO: include associated files in 'info'
# means it needs to be loaded first
info = {
"created": str(datetime.today()),
"build_id": self.build_id,
"id": self.id,
"spec": self.spec,
"exe": self.exe,
}
@ -424,13 +417,13 @@ class ViVenv:
a.table((("key", "value"), *((k, v) for k, v in info.items())))
def activate(*packages: str, track_exe: bool = False, name: str = "") -> None:
def use(*packages: str, track_exe: bool = False, name: str = "") -> None:
"""create a vivenv and append to sys.path
Args:
packages: package specifications with optional version specifiers
track_exe: if true make env python exe specific
name: use as vivenv name, if not provided build_id is used
name: use as vivenv name, if not provided id is used
"""
validate_spec(packages)
vivenv = ViVenv(list(packages), track_exe=track_exe, name=name)
@ -458,7 +451,6 @@ def validate_spec(spec):
def modify_sys_path(new_path: Path):
# remove user-site
for i, path in enumerate(sys.path):
if path == site.USER_SITE:
@ -478,8 +470,48 @@ def get_venvs():
SYS_PATH_TEMPLATE = """__import__("sys").path.append("{path_to_viv}") # noqa"""
REL_SYS_PATH_TEMPLATE = """__import__("sys").path.append(__import__("os").path.expanduser("{path_to_viv}")) # noqa"""
IMPORT_TEMPLATE = """__import__("viv").activate({spec}) # noqa"""
REL_SYS_PATH_TEMPLATE = (
"""__import__("sys").path.append(__import__("os")"""
""".path.expanduser("{path_to_viv}")) # noqa"""
)
IMPORT_TEMPLATE = """__import__("viv").use({spec}) # noqa"""
STANDALONE_TEMPLATE = r"""
# <<<<< auto-generated by daylinmorgan/viv (v.22.12a3)
# fmt: off
{use}
# fmt: on
# >>>>> code golfed with <3
""" # noqa
STANDALONE_TEMPLATE_USE = r"""
def _viv_use(*pkgs: str, track_exe: bool = False, name: str = "") -> None:
i,s,m,e,spec=__import__,str,map,lambda x: True if x else False,[*pkgs]
if not {{*m(type,pkgs)}}=={{s}}: raise ValueError(f"spec: {{pkgs}} is invalid")
ge,sys,P,ew=i("os").getenv,i("sys"),i("pathlib").Path,i("sys").stderr.write
(cache:=(P(ge("XDG_CACHE_HOME",P.home()/".cache"))/"viv"/"venvs")).mkdir(parents=True,exist_ok=True)
((sha256:=i("hashlib").sha256()).update((s(spec)+
(((exe:=("N/A",s(P(i("sys").executable).resolve()))[e(track_exe)])))).encode()))
if (env:=cache/(name if name else (_id:=sha256.hexdigest()))) not in cache.glob("*/") or ge("VIV_FORCE"):
v=e(ge("VIV_VERBOSE"));ew(f"generating new vivenv -> {{env.name}}\n")
i("venv").EnvBuilder(with_pip=True,clear=True).create(env)
with (env/"pip.conf").open("w") as f:f.write("[global]\ndisable-pip-version-check=true")
if (p:=i("subprocess").run([env/"bin"/"pip","install","--force-reinstall",*spec],text=True,
stdout=(-1,None)[v],stderr=(-2,None)[v])).returncode!=0:
if env.is_dir():i("shutil").rmtree(env)
ew(f"pip had non zero exit ({{p.returncode}})\n{{p.stdout}}\n");sys.exit(p.returncode)
with (env/"viv-info.json").open("w") as f:
i("json").dump({{"created":s(i("datetime").datetime.today()),"id":_id,"spec":spec,"exe":exe}},f)
sys.path = [p for p in (*sys.path,s(*(env/"lib").glob("py*/si*"))) if p!=i("site").USER_SITE]
_viv_use({spec})
"""[ # noqa
1:
]
def noqa(txt: str) -> str:
max_length = max(map(len, txt.splitlines()))
return "\n".join((f"{line:{max_length}} # noqa" for line in txt.splitlines()))
def spec_to_import(spec: List[str]) -> None:
@ -508,7 +540,12 @@ def freeze_venv(spec: List[str], path: Path | None = None):
def generate_import(
requirements: Path, reqs: List[str], vivenvs, include_path: bool, keep: bool
requirements: Path,
reqs: List[str],
vivenvs,
include_path: bool,
keep: bool,
standalone: bool,
) -> None:
# TODO: make compatible with Venv class for now just use the name /tmp/
reqs_from_file = []
@ -523,13 +560,13 @@ def generate_import(
echo("generating new vivenv")
vivenv, resolved_spec = freeze_venv(reqs + reqs_from_file)
# update build_id and move vivenv
# update id and move vivenv
vivenv.spec = resolved_spec.splitlines()
vivenv.build_id = get_hash(resolved_spec.splitlines())
echo(f"updated hash -> {vivenv.build_id}")
vivenv.id = get_hash(resolved_spec.splitlines())
echo(f"updated hash -> {vivenv.id}")
if not (c.venvcache / vivenv.build_id).exists():
vivenv.path = vivenv.path.rename(c.venvcache / vivenv.build_id)
if not (c.venvcache / vivenv.id).exists():
vivenv.path = vivenv.path.rename(c.venvcache / vivenv.id)
vivenv.dump_info(write=True)
else:
echo("this vivenv already exists cleaning up temporary vivenv")
@ -542,6 +579,21 @@ def generate_import(
)
echo("see below for import statements\n")
if standalone:
sys.stdout.write(
STANDALONE_TEMPLATE.format(
version=__version__,
use=noqa(
STANDALONE_TEMPLATE_USE.format(
spec=", ".join(f'"{pkg}"' for pkg in resolved_spec.splitlines())
)
),
)
+ "\n"
)
return
if include_path == "absolute":
sys.stdout.write(
SYS_PATH_TEMPLATE.format(
@ -602,7 +654,6 @@ class CustomHelpFormatter(RawDescriptionHelpFormatter, HelpFormatter):
return (", ").join(parts)
def _format_usage(self, *args, **kwargs):
formatted_usage = super()._format_usage(*args, **kwargs)
# patch usage with color formatting
formatted_usage = (
@ -667,7 +718,6 @@ class CustomHelpFormatter(RawDescriptionHelpFormatter, HelpFormatter):
def add_argument(self, action):
if action.help is not SUPPRESS:
# find all invocations
get_invocation = self._format_action_invocation
invocations = [get_invocation(action)]
@ -706,7 +756,7 @@ description = f"""
from command line:
`{a.style("viv -h","bold")}`
within python script:
{a.style('__import__("viv").activate("typer", "rich-click")','bold')}
{a.style('__import__("viv").use("typer", "rich-click")','bold')}
"""
@ -720,7 +770,7 @@ class Viv:
for k, v in self.vivenvs.items():
if name_id == k or v.name == name_id:
matches.append(v)
elif k.startswith(name_id) or v.build_id.startswith(name_id):
elif k.startswith(name_id) or v.id.startswith(name_id):
matches.append(v)
elif v.name.startswith(name_id):
matches.append(v)
@ -755,11 +805,16 @@ class Viv:
"""create import statement from package spec"""
if not args.reqs:
print("must specify a requirement")
error("must specify a requirement")
sys.exit(1)
generate_import(
args.requirements, args.reqs, self.vivenvs, args.path, args.keep
args.requirements,
args.reqs,
self.vivenvs,
args.path,
args.keep,
args.standalone,
)
def list(self, args):
@ -791,7 +846,7 @@ class Viv:
pip_path, python_path = (vivenv.path / "bin" / cmd for cmd in ("pip", "python"))
# todo check for vivenv
print(f"executing command within {args.vivenv}")
echo(f"executing command within {args.vivenv}")
cmd = (
f"{pip_path} {' '.join(args.cmd)}"
@ -912,6 +967,12 @@ class Viv:
help="preserve environment",
action="store_true",
)
p_freeze.add_argument(
"-s",
"--standalone",
help="generate standalone activation function",
action="store_true",
)
p_freeze.add_argument("reqs", help="requirements specifiers", nargs="*")
self._get_subcmd_parser(

10
todo.md
View file

@ -1,7 +1,9 @@
# VIV Todo's
- [ ] swap flake8 for ruff
- [ ] add classifiers for pypi?
- [ ] use config file (probably ini or json / could also allow toml for python>=3.11)
- [x] swap flake8 for ruff
- [x] use stdout and stderr more effectively (or switch to logging?)
- [ ] use config file (probably ini or toml for python>=3.11)
- [ ] enable a garbage collection based on time or file existence (configurable)
- [ ] unit tests
- [ ] unit tests (v important)
- [ ] add more options to filter `viv list`