Skip to content

Commit

Permalink
feat: uvx run <library> to run a package from pypi
Browse files Browse the repository at this point in the history
  • Loading branch information
robinvandernoord committed Apr 17, 2024
1 parent fbd2f6e commit faba096
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 41 deletions.
103 changes: 103 additions & 0 deletions src/uvx/_cli_support.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,110 @@
import functools
import typing

import click
import configuraptor


class State(configuraptor.TypedConfig, configuraptor.Singleton):
"""Global cli app state."""

verbose: bool = False


###
# https://github.com/educationwarehouse/edwh/blob/caae192e016f5dc4677f404f201f798569d3fcb6/src/edwh/helpers.py#L259
###


KEY_ENTER = "\r"
KEY_ARROWUP = "\033[A"
KEY_ARROWDOWN = "\033[B"

T_Key = typing.TypeVar("T_Key", bound=typing.Hashable)


def print_box(label: str, selected: bool, current: bool, number: int, fmt: str = "[%s]", filler: str = "x") -> None:
"""
Print a box for interactive selection.
Helper function for 'interactive_selected_radio_value'.
"""
box = fmt % (filler if selected else " ")
indicator = ">" if current else " "
click.echo(f"{indicator}{number}. {box} {label}")


def interactive_selected_radio_value(
options: list[str] | dict[T_Key, str],
prompt: str = "Select an option (use arrow keys, spacebar, or digit keys, press 'Enter' to finish):",
selected: T_Key | None = None,
) -> str:
"""
Provide an interactive radio box selection in the console.
The user can navigate through the options using the arrow keys,
select an option using the spacebar or digit keys, and finish the selection by pressing 'Enter'.
Args:
options: A list or dict (value: label) of options to be displayed as radio boxes.
prompt (str, optional): A string that is displayed as a prompt for the user.
selected: a pre-selected option.
T_Key means the value has to be the same type as the keys of options.
Example:
options = {1: "something", "two": "else"}
selected = 2 # valid type (int is a key of options)
selected = 1.5 # invalid type (none of the keys of options are a float)
Returns:
str: The selected option value.
Examples:
interactive_selected_radio_value(["first", "second", "third"])
interactive_selected_radio_value({100: "first", 211: "second", 355: "third"})
interactive_selected_radio_value(["first", "second", "third"], selected="third")
interactive_selected_radio_value({1: "first", 2: "second", 3: "third"}, selected=3)
"""
selected_index: int | None = None
current_index = 0

if isinstance(options, list):
labels = options
else:
labels = list(options.values())
options = list(options.keys()) # type: ignore

if selected in options:
selected_index = current_index = options.index(selected) # type: ignore

print_radio_box = functools.partial(print_box, fmt="(%s)", filler="o")

while True:
click.clear()
click.echo(prompt)

for i, option in enumerate(labels, start=1):
print_radio_box(option, i - 1 == selected_index, i - 1 == current_index, i)

key = click.getchar()

if key == KEY_ENTER:
if selected_index is None:
# no you may not leave.
continue
else:
# done!
break

elif key == KEY_ARROWUP: # Up arrow
current_index = (current_index - 1) % len(options)
elif key == KEY_ARROWDOWN: # Down arrow
current_index = (current_index + 1) % len(options)
elif key.isdigit() and 1 <= int(key) <= len(options):
selected_index = int(key) - 1
elif key == " ":
selected_index = current_index

return options[selected_index]
32 changes: 26 additions & 6 deletions src/uvx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import typing
from datetime import datetime
from pathlib import Path
from typing import Annotated
from typing import Annotated, Optional

import plumbum as pb # type: ignore
import rich
Expand All @@ -17,10 +17,9 @@
from result import Err, Ok, Result
from typer import Context

from uvx._constants import BIN_DIR

from .__about__ import __version__
from ._cli_support import State
from ._constants import BIN_DIR
from ._maybe import Maybe
from ._python import _get_package_version, _pip, _python_in_venv, _uv
from .core import (
Expand All @@ -32,6 +31,7 @@
list_packages,
reinstall_package,
run_command,
run_package,
uninstall_package,
upgrade_package,
)
Expand All @@ -45,7 +45,8 @@ def output(result: Result[str, Exception]) -> None:
"""Output positive (ok) result to stdout and error result to stderr."""
match result:
case Ok(msg):
rich.print(msg)
if msg:
rich.print(msg)
case Err(err):
rich.print(err, file=sys.stderr)

Expand Down Expand Up @@ -178,7 +179,11 @@ def inject(into: str, package_specs: list[str]):

@app.command(name="eject")
@app.command(name="uninject")
def uninject(outof: str, package_specs: typing.Annotated[list[str], typer.Argument()] = None):
def uninject(
outof: str,
package_specs: typing.Annotated[list[str], typer.Argument()] = None, # type: ignore
):
"""Uninstall additional packages from a virtual environment managed by uvx."""
output(
eject_packages(
outof,
Expand Down Expand Up @@ -317,7 +322,22 @@ def list_venvs(short: bool = False, verbose: bool = False, json: bool = False):
_list_normal(name, metadata, verbose=verbose)


# todo: run
@app.command("run")
def run(
package: str,
args: typing.Annotated[list[str], typer.Argument()] = None, # type: ignore
keep: bool = False,
python: Annotated[str, typer.Option(help=OPTION_PYTHON_HELP_TEXT)] = "",
no_cache: bool = False,
binary: typing.Annotated[
Optional[str],
typer.Option(
help="Custom name of an executable to run (e.g. 'semantic-release' in the package 'python-semantic-release')"
),
] = None,
):
"""Run a package in a temporary virtual environment."""
output(run_package(package, args or [], keep=keep, python=python, no_cache=no_cache, binary_name=binary or ""))


@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
Expand Down
Loading

0 comments on commit faba096

Please sign in to comment.