Compare commits

..

No commits in common. "bfc2592925c933bac3bb7cf9c09a245755998b0c" and "3453004077e5a14375e9133c20cb9cb726571f85" have entirely different histories.

23 changed files with 94 additions and 512 deletions

View file

@ -16,14 +16,7 @@ repos:
hooks: hooks:
- id: black - id: black
language_version: python language_version: python
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/pycqa/flake8
rev: 'v0.0.245' rev: 6.0.0
hooks: hooks:
- id: ruff - id: flake8
- 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,7 +10,6 @@ types: ## run mypy
bump-version: ## update version and tag commit bump-version: ## update version and tag commit
@echo "bumping to version => $(VERSION)" @echo "bumping to version => $(VERSION)"
@sed -i 's/__version__ = ".*"/__version__ = "$(VERSION)"/g' src/viv/viv.py @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 add src/viv/viv.py && git commit -m "chore: bump version"
@git tag v$(VERSION) @git tag v$(VERSION)
@ -23,16 +22,11 @@ install: ## symlink to $PREFIX
uninstall: ## delete $(PREFIX)/viv uninstall: ## delete $(PREFIX)/viv
rm $(PREFIX)/viv rm $(PREFIX)/viv
TAPES = demo freeze list-info-remove docs: docs/demo.gif docs/freeze.gif ## generate usage examples
GIFS := $(foreach n, $(TAPES), docs/$(n).gif)
docs: $(GIFS) ## generate usage examples
docs/%.gif: docs/%.tape docs/%.gif: docs/%.tape
viv rm $$(viv l -q) viv rm $$(viv l -q)
cd docs; vhs < $*.tape vhs < $<
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 EXAMPLES = cli.py sys_path.py exe_specific.py frozen_import.py named_env.py scrape.py
generate-example-vivens: ## generate-example-vivens: ##

121
README.md
View file

@ -11,34 +11,14 @@
</div> </div>
<br /> <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 ## Setup
### Manual (Recommended)
Start by cloning the repo and symlinking the script for access to the CLI. 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`. By default it will symlink `./src/viv/viv.py` to `~/bin/viv`.
You can set `PREFIX` to symlink to a different location. You can set `PREFIX` to symlink to a different location.
```sh ```sh
git clone --depth 1 --branch v22.12a3 git@github.com:daylinmorgan/viv.git ~/.viv git clone git@github.com:daylinmorgan/viv.git ~/.viv
cd ~/.viv cd ~/.viv
make install # or PREFIX=~/.local/bin make install make install # or PREFIX=~/.local/bin make install
``` ```
@ -49,107 +29,20 @@ Place this directory on the python path in your rc file.
export PYTHONPATH="$PYTHONPATH:$HOME/.viv/src" export PYTHONPATH="$PYTHONPATH:$HOME/.viv/src"
``` ```
Advanced users may recognize that principally, Then in any python script with external dependencies you can add this line.
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.
### Pypi (Not Recommended) ```python
__import__("viv").activate("click")
```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 ## Usage
In any python script with external dependencies you can add this line, To remove all viv venvs:
to automate `vivenv` creation and installation of dependencies.
```python
__import__("viv").use("click")
```
To remove all `vivenvs`:
```sh ```sh
viv remove $(viv list -q) 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 ## Alternatives
### [pip-run](https://github.com/jaraco/pip-run) - `pipx`
- `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)
```

View file

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

View file

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

View file

@ -1,30 +0,0 @@
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,7 +2,6 @@
*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`. *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 # Demo
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/demo.gif" alt="demo" width=600 > <img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/demo.gif" alt="demo" width=600 >
@ -10,9 +9,3 @@
# Freeze # Freeze
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/freeze.gif" alt="demo" width=600 > <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. Or to add simple visualization of data using the wonderful rich library.
""" """
__import__("viv").use("typer", "rich-click") # noqa __import__("viv").activate("typer", "rich-click") # noqa
import typer 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 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. different version of pythons and rely on c extension modules as in numpy.
""" """
__import__("viv").use("numpy", "plotext", track_exe=True) # noqa __import__("viv").activate("numpy", "plotext", track_exe=True) # noqa
import numpy as np import numpy as np
import plotext as plt import plotext as plt

View file

@ -6,7 +6,7 @@ This import statement was generated using
Using viv freeze ensures future runs of this Using viv freeze ensures future runs of this
script will use the same essential environment script will use the same essential environment
""" """
__import__("viv").use( __import__("viv").activate(
"numpy==1.24.0", "numpy==1.24.0",
"pandas==1.5.2", "pandas==1.5.2",
"python-dateutil==2.8.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* *This environment could then be reused by specifying the name*
""" """
__import__("viv").use("rich", name="rich-env") __import__("viv").activate("rich", name="rich-env")
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown 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 https://medium.com/analytics-vidhya/a-super-easy-python-script-for-web-scraping-that-anybody-can-use-d3bd6ab86c89
""" """
__import__("viv").use("requests", "bs4", "rich") # noqa __import__("viv").activate("requests", "bs4", "rich") # noqa
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup

View file

@ -1,38 +0,0 @@
#!/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))

View file

@ -1,141 +0,0 @@
#!/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__("sys").path.append(
__import__("os").path.expanduser("~/.viv/src") __import__("os").path.expanduser("~/.viv/src")
) # noqa # isort: off ) # noqa # isort: off
__import__("viv").use("pyfiglet") # noqa # isort: off __import__("viv").activate("pyfiglet") # noqa # isort: off
import sys import sys

View file

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

View file

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

View file

@ -1,5 +0,0 @@
#!/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

8
setup.cfg Normal file
View file

@ -0,0 +1,8 @@
[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 use # noqa from .viv import activate # noqa

View file

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

10
todo.md
View file

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