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:
- id: black
language_version: python
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.245'
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: ruff
- repo: local
hooks:
- id: set-version
language: script
name: sets __version__ in viv.py
entry: ./scripts/bump-dev.sh
files: viv.py$
- id: flake8

View file

@ -10,7 +10,6 @@ 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)
@ -23,16 +22,11 @@ install: ## symlink to $PREFIX
uninstall: ## delete $(PREFIX)/viv
rm $(PREFIX)/viv
TAPES = demo freeze list-info-remove
GIFS := $(foreach n, $(TAPES), docs/$(n).gif)
docs: $(GIFS) ## generate usage examples
docs: docs/demo.gif docs/freeze.gif ## generate usage examples
docs/%.gif: docs/%.tape
viv rm $$(viv l -q)
cd docs; vhs < $*.tape
clean: ## remove build artifacts
rm -rf {build,dist}
vhs < $<
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,34 +11,14 @@
</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 --depth 1 --branch v22.12a3 git@github.com:daylinmorgan/viv.git ~/.viv
git clone git@github.com:daylinmorgan/viv.git ~/.viv
cd ~/.viv
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"
```
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.
Then in any python script with external dependencies you can add this line.
### Pypi (Not Recommended)
```sh
pip install viv
```python
__import__("viv").activate("click")
```
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
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`:
To remove all viv venvs:
```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
### [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)
```
- `pipx`
- `pip-run`

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 Width 1500
Set Theme "Catppuccin Mocha"
Output ./demo.gif
Output ./docs/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,23 +1,20 @@
Set Height 750
Set Width 1500
Set Theme "Catppuccin Mocha"
Output ./freeze.gif
Output ./docs/freeze.gif
Type "viv freeze --help"
Enter
Sleep 2s
Type "viv list"
Enter
Sleep 2s
Type "viv freeze requests bs4"
Enter
Sleep 7s
Sleep 5s
Type "viv list"
Enter
Sleep 3s
Type "viv freeze requests bs4 --keep --path relative"
Enter
Sleep 7s
Sleep 5s
Type "viv list"
Enter
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`.
<div align="center">
# Demo
<img src="https://raw.githubusercontent.com/daylinmorgan/viv/main/docs/demo.gif" alt="demo" width=600 >
@ -10,9 +9,3 @@
# 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").use("typer", "rich-click") # noqa
__import__("viv").activate("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").use("numpy", "plotext", track_exe=True) # noqa
__import__("viv").activate("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").use(
__import__("viv").activate(
"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").use("rich", name="rich-env")
__import__("viv").activate("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").use("requests", "bs4", "rich") # noqa
__import__("viv").activate("requests", "bs4", "rich") # noqa
import requests
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__("os").path.expanduser("~/.viv/src")
) # noqa # isort: off
__import__("viv").use("pyfiglet") # noqa # isort: off
__import__("viv").activate("pyfiglet") # noqa # isort: off
import sys

View file

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

View file

@ -27,11 +27,3 @@ 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

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
OR
__import__("viv").use("requests", "bs4")
__import__("viv").activate("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-35-g0d0c66d-dev"
__version__ = "22.12a3"
@dataclass
@ -38,7 +38,9 @@ class Config:
"""viv config manager"""
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):
@ -65,22 +67,22 @@ class Spinner:
def write_next(self):
with self._screen_lock:
if not self.spinner_visible:
sys.stderr.write(next(self.spinner))
sys.stdout.write(next(self.spinner))
self.spinner_visible = True
sys.stderr.flush()
sys.stdout.flush()
def remove_spinner(self, cleanup=False):
with self._screen_lock:
if self.spinner_visible:
sys.stderr.write("\b\b\b")
sys.stdout.write("\b\b\b")
# sys.stdout.write("\b")
self.spinner_visible = False
if cleanup:
sys.stderr.write(" ") # overwrite spinner with blank
sys.stdout.write(" ") # overwrite spinner with blank
# sys.stdout.write("\r") # move to next line
# move back then delete the line
sys.stderr.write("\r\033[K")
sys.stderr.flush()
sys.stdout.write("\r\033[K")
sys.stdout.flush()
def spinner_task(self):
while self.busy:
@ -89,18 +91,18 @@ class Spinner:
self.remove_spinner()
def __enter__(self):
if sys.stderr.isatty():
if sys.stdout.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.stderr.isatty():
if sys.stdout.isatty():
self.busy = False
self.remove_spinner(cleanup=True)
else:
sys.stderr.write("\r")
sys.stdout.write("\r")
BOX: Dict[str, str] = {
@ -134,7 +136,7 @@ class Ansi:
metavar: str = "\033[33m" # normal yellow
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__:
setattr(self, attr, "")
@ -194,6 +196,7 @@ 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]]:
@ -246,9 +249,9 @@ class Ansi:
)
)
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")
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")
a = Ansi()
@ -266,12 +269,12 @@ def warn(msg):
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 = f"{a.cyan}Viv{a.end}{a.__dict__[style]}::{a.end} {msg}"
if newline:
output += "\n"
fd.write(output)
sys.stdout.write(output)
def run(
@ -331,15 +334,14 @@ 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())
sha256 = hashlib.sha256()
sha256.update(
(
str(spec) + (str(Path(sys.executable).resolve()) if track_exe else "N/A")
).encode()
)
# generate unique venvs for unique python exe's
if track_exe:
pkg_hash.update(str(Path(sys.executable).resolve()).encode())
return sha256.hexdigest()
return pkg_hash.hexdigest()
class ViVenv:
@ -347,14 +349,14 @@ class ViVenv:
self,
spec: List[str],
track_exe: bool = False,
id: str | None = None,
build_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.id = id if id else get_hash(spec, track_exe)
self.name = name if name else self.id
self.build_id = build_id if build_id else get_hash(spec, track_exe)
self.name = name if name else self.build_id
self.path = path if path else c.venvcache / self.name
@classmethod
@ -370,12 +372,15 @@ class ViVenv:
with (c.venvcache / name / "viv-info.json").open("r") as 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"]
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)
@ -386,6 +391,7 @@ class ViVenv:
f.write("[global]\ndisable-pip-version-check = true")
def install_pkgs(self):
cmd: List[str | Path] = [
self.path / "bin" / "pip",
"install",
@ -400,11 +406,12 @@ 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()),
"id": self.id,
"build_id": self.build_id,
"spec": self.spec,
"exe": self.exe,
}
@ -417,13 +424,13 @@ class ViVenv:
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
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 id is used
name: use as vivenv name, if not provided build_id is used
"""
validate_spec(packages)
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):
# remove user-site
for i, path in enumerate(sys.path):
if path == site.USER_SITE:
@ -470,48 +478,8 @@ 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").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()))
REL_SYS_PATH_TEMPLATE = """__import__("sys").path.append(__import__("os").path.expanduser("{path_to_viv}")) # noqa"""
IMPORT_TEMPLATE = """__import__("viv").activate({spec}) # noqa"""
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(
requirements: Path,
reqs: List[str],
vivenvs,
include_path: bool,
keep: bool,
standalone: bool,
requirements: Path, reqs: List[str], vivenvs, include_path: bool, keep: bool
) -> None:
# TODO: make compatible with Venv class for now just use the name /tmp/
reqs_from_file = []
@ -560,13 +523,13 @@ def generate_import(
echo("generating new vivenv")
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.id = get_hash(resolved_spec.splitlines())
echo(f"updated hash -> {vivenv.id}")
vivenv.build_id = get_hash(resolved_spec.splitlines())
echo(f"updated hash -> {vivenv.build_id}")
if not (c.venvcache / vivenv.id).exists():
vivenv.path = vivenv.path.rename(c.venvcache / vivenv.id)
if not (c.venvcache / vivenv.build_id).exists():
vivenv.path = vivenv.path.rename(c.venvcache / vivenv.build_id)
vivenv.dump_info(write=True)
else:
echo("this vivenv already exists cleaning up temporary vivenv")
@ -579,21 +542,6 @@ 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(
@ -654,6 +602,7 @@ 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 = (
@ -718,6 +667,7 @@ 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)]
@ -756,7 +706,7 @@ description = f"""
from command line:
`{a.style("viv -h","bold")}`
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():
if name_id == k or v.name == name_id:
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)
elif v.name.startswith(name_id):
matches.append(v)
@ -805,16 +755,11 @@ class Viv:
"""create import statement from package spec"""
if not args.reqs:
error("must specify a requirement")
print("must specify a requirement")
sys.exit(1)
generate_import(
args.requirements,
args.reqs,
self.vivenvs,
args.path,
args.keep,
args.standalone,
args.requirements, args.reqs, self.vivenvs, args.path, args.keep
)
def list(self, args):
@ -846,7 +791,7 @@ class Viv:
pip_path, python_path = (vivenv.path / "bin" / cmd for cmd in ("pip", "python"))
# todo check for vivenv
echo(f"executing command within {args.vivenv}")
print(f"executing command within {args.vivenv}")
cmd = (
f"{pip_path} {' '.join(args.cmd)}"
@ -967,12 +912,6 @@ 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,9 +1,7 @@
# VIV Todo's
- [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)
- [ ] swap flake8 for ruff
- [ ] add classifiers for pypi?
- [ ] 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)
- [ ] unit tests (v important)
- [ ] add more options to filter `viv list`
- [ ] unit tests