diff --git a/README.md b/README.md index 82e65c7..e7d7e14 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,16 @@ uv install uvx pipx install uvx ``` +## Usage +```bash +uvx +``` + +Run `uvx` without any arguments to see all possible subcommands. + ## License -`usvx` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. +`uvx` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. ## Changelog diff --git a/pyproject.toml b/pyproject.toml index 9d4c366..052ba6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,9 @@ dependencies = [ "typer", "plumbum", "threadful>=0.2", + "rich", "quickle", + "packaging", ] [project.optional-dependencies] diff --git a/src/uvx/_python.py b/src/uvx/_python.py index 1cb54e0..c5260ff 100644 --- a/src/uvx/_python.py +++ b/src/uvx/_python.py @@ -1,8 +1,11 @@ +import sys import textwrap from pathlib import Path import plumbum # type: ignore -from plumbum.cmd import grep, uv # type: ignore +from plumbum.cmd import grep # type: ignore + +_uv = plumbum.local[sys.executable]["-m", "uv"] def _run_python_in_venv(*args: str, venv: Path) -> str: @@ -41,4 +44,4 @@ def get_package_version(package: str) -> str: """Get the currently installed version of a specific package.""" # assumes `with virtualenv(venv)` block executing this function # uv pip freeze | grep ^su6== - return (uv["pip", "freeze"] | grep[f"^{package}=="])().strip().split("==")[-1] + return (_uv["pip", "freeze"] | grep[f"^{package}=="])().strip().split("==")[-1] diff --git a/src/uvx/cli.py b/src/uvx/cli.py index 25e2697..77b9edf 100644 --- a/src/uvx/cli.py +++ b/src/uvx/cli.py @@ -1,6 +1,10 @@ """This file builds the Typer cli.""" +import os import subprocess # nosec +import sys +from datetime import datetime +from pathlib import Path from typing import Optional import plumbum # type: ignore @@ -8,6 +12,9 @@ import typer from typer import Context +from uvx._constants import BIN_DIR + +from .__about__ import __version__ from .core import ( as_virtualenv, format_bools, @@ -25,6 +32,7 @@ @app.command() def install(package_name: str, force: bool = False, python: str = ""): """Install a package (by pip name).""" + # todo: support 'install .' install_package(package_name, python=python, force=force) @@ -132,3 +140,70 @@ def runpython(venv: str, ctx: Context): # version or --version (incl. 'uv' version and Python version) # ... + + +def add_to_bashrc(text: str, with_comment: bool = True): + """Add text to ~/.bashrc, usually with a comment (uvx + timestamp).""" + with (Path.home() / ".bashrc").resolve().open("a") as f: + now = str(datetime.now()).split(".")[0] + final_text = "\n" + final_text += f"# Added by `uvx` at {now}\n" if with_comment else "" + final_text += text + "\n" + f.write(final_text) + + +@app.command() +def ensurepath(force: bool = False): + """Update ~/.bashrc with a PATH that includes the local bin directory that uvx uses.""" + env_path = os.getenv("PATH", "") + bin_in_path = str(BIN_DIR) in env_path.split(":") + + if bin_in_path and not force: + rich.print( + f"[yellow]{BIN_DIR} is already added to your path. Use '--force' to add it to your .bashrc file anyway.[/yellow]" + ) + exit(1) + + add_to_bashrc(f'export PATH="$PATH:{BIN_DIR}"') + + +@app.command() +def completions(): # noqa + """ + Use --install-completion to install the autocomplete script, \ + or --show-completion to see what would be installed. + """ + rich.print("Use 'uvx --install-completion' to install the autocomplete script to your '.bashrc' file.") + + +def version_callback(): + """Show the current versions when running with --version.""" + rich.print("uvx", __version__) + run_command("uv", "--version", printfn=rich.print) + rich.print("Python", sys.version.split(" ")[0]) + + +@app.callback(invoke_without_command=True, no_args_is_help=True) +def main( + ctx: typer.Context, + # stops the program: + version: bool = False, +) -> None: # noqa + """ + This callback will run before every command, setting the right global flags. + + Args: + ctx: context to determine if a subcommand is passed, etc + + version: display current version? + + """ + if version: + version_callback() + elif not ctx.invoked_subcommand: + rich.print("[yellow]Missing subcommand. Try `uvx --help` for more info.[/yellow]") + # else: just continue + + +if __name__ == "__main__": # pragma: no cover + app() diff --git a/src/uvx/core.py b/src/uvx/core.py index adb4910..5d6528d 100644 --- a/src/uvx/core.py +++ b/src/uvx/core.py @@ -10,12 +10,11 @@ import plumbum # type: ignore import rich from plumbum import local # type: ignore -from plumbum.cmd import uv as _uv # type: ignore from threadful import thread from threadful.bonus import animate from ._constants import WORK_DIR -from ._python import get_package_version, get_python_executable, get_python_version +from ._python import _uv, get_package_version, get_python_executable, get_python_version from ._symlinks import find_symlinks, install_symlinks, remove_symlink from .metadata import Metadata, collect_metadata, read_metadata, store_metadata @@ -283,11 +282,13 @@ def list_packages() -> typing.Generator[tuple[str, Metadata | None], None, None] yield subdir.name, metadata -def run_command(command: str, *args: str): +def run_command(command: str, *args: str, printfn: typing.Callable[..., None] = print): """Run a command via plumbum without raising an error on exception.""" # retcode = None makes the command not raise an exception on error: exit_code, stdout, stderr = plumbum.local[command][args].run(retcode=None) - print(stdout) - print(stderr, file=sys.stderr) + if stdout: + printfn(stdout.strip()) + if stderr: + printfn(stderr.strip(), file=sys.stderr) return exit_code