diff --git a/belay/cli/cache.py b/belay/cli/cache.py index 1cc882be..9b80563d 100644 --- a/belay/cli/cache.py +++ b/belay/cli/cache.py @@ -4,30 +4,37 @@ with contextlib.suppress(ImportError): import readline import shutil +import sys +from typing import Annotated -import typer -from typer import Argument, Option, Typer +from cyclopts import App, Parameter from belay.project import find_cache_folder -app = Typer(no_args_is_help=True, help="Perform action's on Belay's cache.") +app = App(help="Perform action's on Belay's cache.") -@app.command() +@app.command def clear( - prefix: str = Argument("", help="Clear all caches that start with this."), - yes: bool = Option( - False, - "--yes", - "-y", - help='Automatically answer "yes" to all confirmation prompts.', - ), - all: bool = Option(False, "--all", "-a", help="Clear all caches."), + prefix: str = "", + *, + yes: Annotated[bool, Parameter(alias="-y")] = False, + all: Annotated[bool, Parameter(alias="-a")] = False, ): - """Clear cache.""" + """Clear cache. + + Parameters + ---------- + prefix : str + Clear all caches that start with this. + yes : bool + Automatically answer "yes" to all confirmation prompts. + all : bool + Clear all caches. + """ if (not prefix and not all) or (prefix and all): print('Either provide a prefix OR set the "--all" flag.') - raise typer.Exit() + sys.exit(1) cache_folder = find_cache_folder() @@ -37,13 +44,15 @@ def clear( if not cache_paths: print(f'No caches found starting with "{prefix}"') - raise typer.Exit() + sys.exit(1) if not yes: print("Found caches:") for cache_name in cache_names: print(f" • {cache_name}") - typer.confirm("Clear these caches?", abort=True) + response = input("Clear these caches? [y/N]: ") + if response.lower() not in ("y", "yes"): + sys.exit(1) for path in cache_paths: if path.is_file(): @@ -52,7 +61,7 @@ def clear( shutil.rmtree(path) -@app.command() +@app.command def list(): """List cache elements.""" cache_folder = find_cache_folder() @@ -62,7 +71,7 @@ def list(): print(item) -@app.command() +@app.command def info(): """Display cache location and size.""" cache_folder = find_cache_folder() diff --git a/belay/cli/common.py b/belay/cli/common.py index 66d7fd8b..2cf32ff6 100644 --- a/belay/cli/common.py +++ b/belay/cli/common.py @@ -1,16 +1,26 @@ -from belay.pyboard import PyboardException +from contextlib import contextmanager +from typing import Annotated + +from cyclopts import Parameter -help_port = "Port (like /dev/ttyUSB0) or WebSocket (like ws://192.168.1.100) of device." -help_password = "Password for communication methods (like WebREPL) that require authentication." # nosec # noqa: S105 +from belay.pyboard import PyboardException +# Custom annotated types for consistent CLI parameter help +PortStr = Annotated[ + str, + Parameter(help="Port (like /dev/ttyUSB0) or WebSocket (like ws://192.168.1.100) of device."), +] +PasswordStr = Annotated[ + str, + Parameter(help="Password for communication methods (like WebREPL) that require authentication."), +] -class remove_stacktrace: # noqa: N801 - def __enter__(self): - return self - def __exit__(self, exc_type, exc_value, traceback): - if exc_type is not None and issubclass(exc_type, PyboardException): - print(exc_value) - return True # suppress the full stack trace - else: - return False # let other exceptions propagate normally +@contextmanager +def remove_stacktrace(): + """Context manager that suppresses PyboardException stack traces and prints only the error message.""" + try: + yield + except PyboardException as e: + print(e) + # Exception is handled, don't re-raise diff --git a/belay/cli/exec.py b/belay/cli/exec.py index 04bc6f54..873ccd56 100644 --- a/belay/cli/exec.py +++ b/belay/cli/exec.py @@ -1,17 +1,15 @@ -from pathlib import Path - -from typer import Argument, Option - from belay import Device -from belay.cli.common import help_password, help_port, remove_stacktrace +from belay.cli.common import PasswordStr, PortStr, remove_stacktrace + +def exec(port: PortStr, statement: str, *, password: PasswordStr = ""): + """Execute python statement on-device. -def exec( - port: str = Argument(..., help=help_port), - statement: str = Argument(..., help="Statement to execute on-device."), - password: str = Option("", help=help_password), -): - """Execute python statement on-device.""" + Parameters + ---------- + statement : str + Statement to execute on-device. + """ device = Device(port, password=password) with remove_stacktrace(): device(statement) diff --git a/belay/cli/info.py b/belay/cli/info.py index 889c17fc..eb8adede 100644 --- a/belay/cli/info.py +++ b/belay/cli/info.py @@ -1,13 +1,8 @@ -from typer import Argument, Option - from belay import Device -from belay.cli.common import help_password, help_port +from belay.cli.common import PasswordStr, PortStr -def info( - port: str = Argument(..., help=help_port), - password: str = Option("", help=help_password), -): +def info(port: PortStr, *, password: PasswordStr = ""): """Display device firmware information.""" device = Device(port, password=password) version_str = "v" + ".".join(str(x) for x in device.implementation.version) diff --git a/belay/cli/install.py b/belay/cli/install.py index 71ce440d..6c669078 100644 --- a/belay/cli/install.py +++ b/belay/cli/install.py @@ -2,27 +2,43 @@ from functools import partial from pathlib import Path from tempfile import TemporaryDirectory -from typing import List, Optional +from typing import Annotated, Optional +from cyclopts import Parameter from rich.progress import Progress -from typer import Argument, Option from belay import Device -from belay.cli.common import help_password, help_port, remove_stacktrace +from belay.cli.common import PasswordStr, PortStr, remove_stacktrace from belay.cli.sync import sync_device as _sync_device from belay.project import find_project_folder, load_groups, load_pyproject def install( - port: str = Argument(..., help=help_port), - password: str = Option("", help=help_password), - mpy_cross_binary: Optional[Path] = Option(None, help="Compile py files with this executable."), - run: Optional[Path] = Option(None, help="Run script on-device after installing."), - main: Optional[Path] = Option(None, help="Sync script to /main.py after installing."), - with_groups: List[str] = Option(None, "--with", help="Include specified optional dependency group."), - follow: bool = Option(False, "--follow", "-f", help="Follow the stdout after upload."), + port: PortStr, + *, + password: PasswordStr = "", + mpy_cross_binary: Optional[Path] = None, + run: Optional[Path] = None, + main: Optional[Path] = None, + with_groups: Annotated[Optional[list[str]], Parameter(name="--with")] = None, + follow: Annotated[bool, Parameter(alias="-f")] = False, ): - """Sync dependencies and project itself to device.""" + """Sync dependencies and project itself to device. + + Parameters + ---------- + mpy_cross_binary : Optional[Path] + Compile py files with this executable. + run : Optional[Path] + Run script on-device after installing. + main : Optional[Path] + Sync script to /main.py after installing. + with_groups : Optional[list[str]] + Include specified optional dependency group. + follow : bool + Follow the stdout after upload. + """ + with_groups = with_groups or [] if run and run.suffix != ".py": raise ValueError("Run script MUST be a python file.") if main and main.suffix != ".py": @@ -94,7 +110,7 @@ def progress_update(description=None, **kwargs): device(content) return - # Reset device so ``main.py`` has a chance to execute. + # Reset device so `main.py` has a chance to execute. device.soft_reset() if follow: device.terminal() diff --git a/belay/cli/main.py b/belay/cli/main.py index acfcff1a..be2ba607 100644 --- a/belay/cli/main.py +++ b/belay/cli/main.py @@ -3,13 +3,12 @@ import subprocess # nosec import sys from tempfile import TemporaryDirectory -from typing import List +from typing import Annotated -import typer -from typer import Option +from cyclopts import App, Parameter import belay -from belay.cli import cache +from belay.cli.cache import app as cache_app from belay.cli.clean import clean from belay.cli.exec import exec from belay.cli.info import info @@ -22,28 +21,27 @@ from belay.cli.update import update from belay.project import load_groups -app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False) -app.add_typer(cache.app, name="cache") - -app.command()(clean) -app.command()(exec) -app.command()(info) -app.command()(install) -app.command()(new) -app.command()(run) -app.command()(select) -app.command()(sync) -app.command()(terminal) -app.command()(update) - - -def run_exec(command: List[str]): +app = App(version_flags=("--version", "-v"), help_format="markdown") +app.command(cache_app, name="cache") +app.command(clean) +app.command(exec) +app.command(info) +app.command(install) +app.command(new) +app.command(run) +app.command(select) +app.command(sync) +app.command(terminal) +app.command(update) + + +def run_exec(command: list[str]): """Enable virtual-environment and run command.""" groups = load_groups() virtual_env = os.environ.copy() # Add all dependency groups to the micropython path. # This flattens all dependencies to a single folder and fetches fresh - # copies of dependencies in ``develop`` mode. + # copies of dependencies in `develop` mode. with TemporaryDirectory() as tmp_dir: virtual_env["MICROPYPATH"] = f".:{tmp_dir}" for group in groups: @@ -67,38 +65,17 @@ def _get(indexable, index, default=None): def run_app(*args, **kwargs): - """Add CLI hacks that are not Typer-friendly here.""" + """Add CLI hacks that are not Cyclopts-friendly here.""" command = _get(sys.argv, 1) - - try: - exec_path = shutil.which(sys.argv[2]) - except IndexError: - exec_path = None - - if command == "run" and exec_path: - # Special subcommand override. - run_exec(sys.argv[2:]) + if command == "run": + try: + exec_path = shutil.which(sys.argv[2]) + except IndexError: + exec_path = None + if exec_path is not None: + run_exec(sys.argv[2:]) + else: + app(*args, **kwargs) else: - # Common-case; use Typer functionality. + # Common-case; use Cyclopts functionality. app(*args, **kwargs) - - -def version_callback(value: bool): - if not value: - return - print(belay.__version__) - raise typer.Exit() - - -@app.callback() -def common( - ctx: typer.Context, - version: bool = Option( - None, - "--version", - "-v", - callback=version_callback, - help="Display Belay's version.", - ), -): - pass diff --git a/belay/cli/new.py b/belay/cli/new.py index 6d95c525..4da8b17b 100644 --- a/belay/cli/new.py +++ b/belay/cli/new.py @@ -1,19 +1,19 @@ +import importlib.resources as importlib_resources import re import shutil -import sys from pathlib import Path from packaging.utils import canonicalize_name -from typer import Argument -if sys.version_info < (3, 9, 0): - import importlib_resources -else: - import importlib.resources as importlib_resources +def new(project_name: str): + """Create a new micropython project structure. -def new(project_name: str = Argument(..., help="Project Name.")): - """Create a new micropython project structure.""" + Parameters + ---------- + project_name : str + Project Name. + """ package_name = canonicalize_name(project_name) dst_dir = Path() / project_name template_dir = importlib_resources.files("belay") / "cli" / "new_template" diff --git a/belay/cli/questionary_ext.py b/belay/cli/questionary_ext.py index 85224175..d0bef7e2 100644 --- a/belay/cli/questionary_ext.py +++ b/belay/cli/questionary_ext.py @@ -1,8 +1,7 @@ -from typing import Any, Dict, Optional, Sequence, Union +from collections.abc import Sequence +from typing import Any, Optional, Union -from prompt_toolkit import PromptSession from prompt_toolkit.application import Application -from prompt_toolkit.formatted_text import to_formatted_text from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys from prompt_toolkit.styles import Style, merge_styles @@ -17,51 +16,11 @@ from questionary.question import Question -def press_any_key_to_continue( - message: str = "Press any key to continue...", - style: Optional[Style] = None, - **kwargs, -): - """Wait until user presses any key to continue. - - Example: - >>> import questionary - >>> questionary.press_any_key_to_continue().ask() - Press any key to continue... - None - - Args: - message: Question text. - - style: A custom color and style for the question parts. You can - configure colors as well as font types for different elements. - """ - merged_style = merge_styles([DEFAULT_STYLE, style]) - - def get_prompt_tokens(): - tokens = [] - - tokens.append(("class:question", f" {message} ")) - - return to_formatted_text(tokens) - - def exit_with_result(event): - event.app.exit(result=None) - - bindings = KeyBindings() - - @bindings.add(Keys.Any) - def any_key(event): - exit_with_result(event) - - return Question(PromptSession(get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs).app) - - def select_table( message: str, header: str, - choices: Sequence[Union[str, Choice, Dict[str, Any]]], - default: Optional[Union[str, Choice, Dict[str, Any]]] = None, + choices: Sequence[Union[str, Choice, dict[str, Any]]], + default: Optional[Union[str, Choice, dict[str, Any]]] = None, qmark: str = DEFAULT_QUESTION_PREFIX, pointer: Optional[str] = DEFAULT_SELECTED_POINTER, style: Optional[Style] = None, @@ -75,17 +34,17 @@ def select_table( Args: message: Question text header: Table header text - choices: Items shown in the selection, this can contain :class:`Choice` or - or :class:`Separator` objects or simple items as strings. Passing - :class:`Choice` objects, allows you to configure the item more + choices: Items shown in the selection, this can contain `Choice` or + or `Separator` objects or simple items as strings. Passing + `Choice` objects, allows you to configure the item more (e.g. preselecting it or disabling it). default: A value corresponding to a selectable item in the choices, to initially set the pointer position to. qmark: Question prefix displayed in front of the question. - By default this is a ``?``. + By default this is a `?`. pointer: Pointer symbol in front of the currently highlighted element. - By default this is a ``»``. - Use ``None`` to disable it. + By default this is a `»`. + Use `None` to disable it. style: A custom color and style for the question parts. You can configure colors as well as font types for different elements. use_indicator: Flag to enable the small indicator in front of the @@ -94,7 +53,7 @@ def select_table( Returns ------- - :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + Question instance, ready to be prompted (using `.ask()`). """ if choices is None or len(choices) == 0: raise ValueError("A list of choices needs to be provided.") diff --git a/belay/cli/run.py b/belay/cli/run.py index e096912a..dbc4e155 100644 --- a/belay/cli/run.py +++ b/belay/cli/run.py @@ -1,26 +1,26 @@ from pathlib import Path -from typer import Argument, Option - from belay import Device -from belay.cli.common import help_password, help_port, remove_stacktrace +from belay.cli.common import PasswordStr, PortStr, remove_stacktrace -def run( - port: str = Argument(..., help=help_port), - file: Path = Argument(..., help="File to run on-device."), - password: str = Option("", help=help_password), -): +def run(port: PortStr, file: Path, *, password: PasswordStr = ""): """Run file on-device. - If the first argument, ``port``, is resolvable to an executable, + If the first argument, `port`, is resolvable to an executable, the remainder of the command will be interpreted as a shell command that will be executed in a pseudo-micropython-virtual-environment. - As of right now, this just sets ``MICROPYPATH`` to all of the dependency - groups' folders. E.g:: + As of right now, this just sets `MICROPYPATH` to all of the dependency + groups' folders. E.g: - belay run micropython -m unittest + ```bash + belay run micropython -m unittest + ``` + Parameters + ---------- + file : Path + File to run on-device. """ content = file.read_text(encoding="utf-8") with Device(port, password=password) as device, remove_stacktrace(): diff --git a/belay/cli/select.py b/belay/cli/select.py index a05c622d..7f79b763 100644 --- a/belay/cli/select.py +++ b/belay/cli/select.py @@ -1,12 +1,12 @@ import asyncio import contextlib +import sys import questionary -import typer from questionary import Choice from belay import Device, DeviceMeta -from belay.cli.questionary_ext import press_any_key_to_continue, select_table +from belay.cli.questionary_ext import select_table from belay.usb_specifier import list_devices @@ -20,7 +20,7 @@ async def blink_loop(device): async def blink_until_prompt(device): blink_task = asyncio.create_task(blink_loop(device)) - await press_any_key_to_continue().ask_async() + await questionary.press_any_key_to_continue().ask_async() blink_task.cancel() with contextlib.suppress(asyncio.CancelledError): await blink_task @@ -117,7 +117,7 @@ def select(): if not choices: print("Detected no devices.") - raise typer.Exit(code=1) + sys.exit(1) device_index = select_table( "Select USB Device (Use arrow keys):", diff --git a/belay/cli/sync.py b/belay/cli/sync.py index bee405d1..febd10cd 100644 --- a/belay/cli/sync.py +++ b/belay/cli/sync.py @@ -1,13 +1,11 @@ -from contextlib import nullcontext from functools import partial from pathlib import Path -from typing import List, Optional +from typing import Optional from rich.progress import Progress -from typer import Argument, Option from belay import Device -from belay.cli.common import help_password, help_port +from belay.cli.common import PasswordStr, PortStr def sync_device(device, folder, progress_update, **kwargs): @@ -16,19 +14,30 @@ def sync_device(device, folder, progress_update, **kwargs): def sync( - port: str = Argument(..., help=help_port), - folder: Path = Argument(..., help="Path of local file or folder to sync."), - dst: str = Option("/", help="Destination directory to unpack folder contents to."), - password: str = Option("", help=help_password), - keep: Optional[List[str]] = Option(None, help="Files to keep."), - ignore: Optional[List[str]] = Option(None, help="Files to ignore."), - mpy_cross_binary: Optional[Path] = Option(None, help="Compile py files with this executable."), + port: PortStr, + folder: Path, + *, + dst: str = "/", + password: PasswordStr = "", + keep: Optional[list[str]] = None, + ignore: Optional[list[str]] = None, + mpy_cross_binary: Optional[Path] = None, ): - """Synchronize a folder to device.""" - # Typer issues: https://github.com/tiangolo/typer/issues/410 - keep = keep if keep else None - ignore = ignore if ignore else None - + """Synchronize a folder to device. + + Parameters + ---------- + folder : Path + Path of local file or folder to sync. + dst : str + Destination directory to unpack folder contents to. + keep : Optional[list[str]] + Files to keep. + ignore : Optional[list[str]] + Files to ignore. + mpy_cross_binary : Optional[Path] + Compile py files with this executable. + """ with Device(port, password=password) as device, Progress() as progress: task_id = progress.add_task("") diff --git a/belay/cli/terminal.py b/belay/cli/terminal.py index 7be0768d..1e944ee4 100644 --- a/belay/cli/terminal.py +++ b/belay/cli/terminal.py @@ -1,13 +1,8 @@ -from typer import Argument, Option - from belay import Device -from belay.cli.common import help_password, help_port +from belay.cli.common import PasswordStr, PortStr -def terminal( - port: str = Argument(..., help=help_port), - password: str = Option("", help=help_password), -): +def terminal(port: PortStr, *, password: PasswordStr = ""): """Open up an interactive REPL. Press ctrl+] to exit. diff --git a/belay/cli/update.py b/belay/cli/update.py index 7411a32b..aa701070 100644 --- a/belay/cli/update.py +++ b/belay/cli/update.py @@ -1,14 +1,17 @@ -from typing import List - from rich.console import Console -from typer import Argument from belay.cli.clean import clean from belay.project import load_groups -def update(packages: List[str] = Argument(None, help="Specific package(s) to update.")): - """Download new versions of dependencies.""" +def update(*packages: str): + """Download new versions of dependencies. + + Parameters + ---------- + *packages : str + Specific package(s) to update. + """ console = Console() groups = load_groups() packages = packages if packages else None diff --git a/belay/device.py b/belay/device.py index 17bf5dd5..85cd7912 100644 --- a/belay/device.py +++ b/belay/device.py @@ -2,6 +2,7 @@ import atexit import concurrent.futures import contextlib +import importlib.resources as importlib_resources import linecache import re import shutil @@ -11,7 +12,7 @@ from tempfile import TemporaryDirectory from textwrap import dedent from types import ModuleType -from typing import Any, Callable, Optional, TextIO, Tuple, TypeVar, Union, overload +from typing import Any, Callable, Optional, TextIO, TypeVar, Union, overload from serial import SerialException from serial.tools.miniterm import Miniterm @@ -48,11 +49,6 @@ from .utils import Sentinel from .webrepl import WebreplToSerial -if sys.version_info < (3, 9, 0): - import importlib_resources -else: - import importlib.resources as importlib_resources - P = ParamSpec("P") R = TypeVar("R") @@ -510,7 +506,7 @@ def data_consumer(data): return result - def proxy(self, cmd: str, delete: Optional[bool] = None) -> Union[ProxyObject, Tuple[ProxyObject, ...]]: + def proxy(self, cmd: str, delete: Optional[bool] = None) -> Union[ProxyObject, tuple[ProxyObject, ...]]: """Create a :class:`.ProxyObject` for interacting with remote objects. This is a convenience method that combines object creation and proxy wrapping. diff --git a/belay/device_support.py b/belay/device_support.py index c236c992..7582b714 100644 --- a/belay/device_support.py +++ b/belay/device_support.py @@ -1,7 +1,7 @@ import math from dataclasses import dataclass from threading import Lock -from typing import Callable, Literal, Optional, Tuple, get_args +from typing import Callable, Literal, Optional, get_args from attrs import define, field @@ -47,10 +47,10 @@ class Implementation: """ name: str - version: Tuple[int, int, int] = (0, 0, 0) + version: tuple[int, int, int] = (0, 0, 0) platform: str = "" arch: Optional[str] = field(default=None, converter=_arch_converter) - emitters: Tuple[str, ...] = () + emitters: tuple[str, ...] = () _method_metadata_counter_lock = Lock() diff --git a/belay/helpers.py b/belay/helpers.py index 997cc5ee..d522118f 100644 --- a/belay/helpers.py +++ b/belay/helpers.py @@ -1,3 +1,4 @@ +import importlib.resources as importlib_resources import secrets import string import sys @@ -7,11 +8,6 @@ from . import nativemodule_fnv1a32, snippets -if sys.version_info < (3, 9, 0): - import importlib_resources -else: - import importlib.resources as importlib_resources - _python_identifier_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits diff --git a/belay/inspect.py b/belay/inspect.py index 10bb8f55..e183d16b 100644 --- a/belay/inspect.py +++ b/belay/inspect.py @@ -1,6 +1,7 @@ import ast import inspect import re +from collections.abc import Sequence from io import StringIO from tokenize import ( COMMENT, @@ -12,7 +13,6 @@ generate_tokens, untokenize, ) -from typing import Sequence, Tuple _pat_no_decorators = re.compile(r"^(\s*def\s)|(\s*async\s+def\s)|(.*(? Tuple[str, int, str]: +def getsource(f, *, strip_signature=False) -> tuple[str, int, str]: """Get source code with mild post processing. * strips leading decorators. diff --git a/belay/packagemanager/group.py b/belay/packagemanager/group.py index 873025df..739315b0 100644 --- a/belay/packagemanager/group.py +++ b/belay/packagemanager/group.py @@ -3,7 +3,7 @@ import tempfile from contextlib import nullcontext from pathlib import Path -from typing import List, Optional +from typing import Optional from rich.console import Console @@ -100,7 +100,7 @@ def _download_package(self, package_name) -> bool: def download( self, - packages: Optional[List[str]] = None, + packages: Optional[list[str]] = None, console: Optional[Console] = None, ) -> None: """Download dependencies. diff --git a/belay/packagemanager/models.py b/belay/packagemanager/models.py index ce142e48..5d83148a 100644 --- a/belay/packagemanager/models.py +++ b/belay/packagemanager/models.py @@ -2,7 +2,7 @@ """ from pathlib import Path -from typing import Dict, List, Optional +from typing import Optional from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict, field_validator @@ -19,7 +19,7 @@ class DependencySourceConfig(BaseModel): rename_to_init: bool = False -DependencyList = List[DependencySourceConfig] +DependencyList = list[DependencySourceConfig] def _dependencies_name_validator(dependencies) -> dict: @@ -29,7 +29,7 @@ def _dependencies_name_validator(dependencies) -> dict: return dependencies -def _dependencies_preprocessor(dependencies) -> Dict[str, List[dict]]: +def _dependencies_preprocessor(dependencies) -> dict[str, list[dict]]: """Preprocess various dependencies based on dtype. * ``str`` -> single dependency that may get renamed to __init__.py, if appropriate. @@ -92,7 +92,7 @@ def walk_dependencies(packages: dict): class GroupConfig(BaseModel): optional: bool = False - dependencies: Dict[str, DependencyList] = {} + dependencies: dict[str, DependencyList] = {} ############## # VALIDATORS # @@ -129,13 +129,13 @@ class BelayConfig(BaseModel): ignore: Optional[list] = [] # "main" dependencies - dependencies: Dict[str, DependencyList] = {} + dependencies: dict[str, DependencyList] = {} # Path to where dependency groups should be stored relative to project's root. dependencies_path: Path = Path(".belay/dependencies") # Other dependencies - group: Dict[str, GroupConfig] = {} + group: dict[str, GroupConfig] = {} ############## # VALIDATORS # diff --git a/belay/project.py b/belay/project.py index d2900dbf..5a972e60 100644 --- a/belay/project.py +++ b/belay/project.py @@ -1,7 +1,7 @@ import platform from functools import lru_cache from pathlib import Path -from typing import List, Union +from typing import Union import tomli @@ -80,7 +80,7 @@ def load_pyproject() -> BelayConfig: @lru_cache -def load_groups() -> List[Group]: +def load_groups() -> list[Group]: config = load_pyproject() groups = [Group("main", dependencies=config.dependencies)] groups.extend(Group(name, **definition.model_dump()) for name, definition in config.group.items()) diff --git a/belay/telnetlib.py b/belay/telnetlib.py index 9654c793..81baa812 100644 --- a/belay/telnetlib.py +++ b/belay/telnetlib.py @@ -246,7 +246,7 @@ def msg(self, msg, *args): """ if self.debuglevel > 0: - print("Telnet(%s,%s):" % (self.host, self.port), end=" ") + print("Telnet({},{}):".format(self.host, self.port), end=" ") if args: print(msg % args) else: diff --git a/belay/typing.py b/belay/typing.py index 136bfd9a..22a27c06 100644 --- a/belay/typing.py +++ b/belay/typing.py @@ -1,7 +1,8 @@ +from collections.abc import Generator from pathlib import Path -from typing import Callable, Dict, Generator, List, Set, Union +from typing import Callable, Union -PythonLiteral = Union[None, bool, bytes, int, float, str, List, Dict, Set] +PythonLiteral = Union[None, bool, bytes, int, float, str, list, dict, set] BelayGenerator = Generator[PythonLiteral, None, None] BelayReturn = Union[BelayGenerator, PythonLiteral] BelayCallable = Callable[..., BelayReturn] diff --git a/belay/usb_specifier.py b/belay/usb_specifier.py index a1e36464..6216225d 100644 --- a/belay/usb_specifier.py +++ b/belay/usb_specifier.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional from pydantic import BaseModel, Field from serial.tools.list_ports import comports @@ -13,7 +13,7 @@ def _normalize(val): return val -def _dict_is_subset(subset: Dict, superset: Dict) -> bool: +def _dict_is_subset(subset: dict, superset: dict) -> bool: """Tests if ``subset`` dictionary is a subset of ``superset`` dictionary.""" for subset_key, subset_value in subset.items(): try: @@ -65,7 +65,7 @@ def populated(self): return bool(self.model_dump(exclude_none=True)) -def list_devices() -> List[UsbSpecifier]: +def list_devices() -> list[UsbSpecifier]: """Lists available device ports. Returns diff --git a/examples/06_external_modules_and_file_sync/circuitpython.py b/examples/06_external_modules_and_file_sync/circuitpython.py index 419ac4ac..08e0c1d9 100644 --- a/examples/06_external_modules_and_file_sync/circuitpython.py +++ b/examples/06_external_modules_and_file_sync/circuitpython.py @@ -42,7 +42,7 @@ def set_led(value): @device.task def read_file(fn): - with open(fn, "r") as f: + with open(fn) as f: return f.read() diff --git a/examples/06_external_modules_and_file_sync/main.py b/examples/06_external_modules_and_file_sync/main.py index a6385321..4c9e1ef9 100644 --- a/examples/06_external_modules_and_file_sync/main.py +++ b/examples/06_external_modules_and_file_sync/main.py @@ -59,7 +59,7 @@ def set_somemodule_led(pin, value): @device.task def read_file(fn): - with open(fn, "r") as f: + with open(fn) as f: return f.read() diff --git a/poetry.lock b/poetry.lock index e572f2cc..2d3e7bc3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -164,9 +164,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - [[package]] name = "appnope" version = "0.1.4" @@ -253,9 +250,6 @@ files = [ {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] @@ -450,7 +444,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -465,12 +459,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "debug", "dev", "docs"] +groups = ["debug", "dev", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", debug = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", docs = "sys_platform == \"win32\""} +markers = {debug = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", docs = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -560,6 +554,30 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cyclopts" +version = "3.24.0" +description = "Intuitive, easy CLIs based on type hints." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cyclopts-3.24.0-py3-none-any.whl", hash = "sha256:809d04cde9108617106091140c3964ee6fceb33cecdd537f7ffa360bde13ed71"}, + {file = "cyclopts-3.24.0.tar.gz", hash = "sha256:de6964a041dfb3c57bf043b41e68c43548227a17de1bad246e3a0bfc5c4b7417"}, +] + +[package.dependencies] +attrs = ">=23.1.0" +docstring-parser = {version = ">=0.15", markers = "python_version < \"4.0\""} +rich = ">=13.6.0" +rich-rst = ">=1.3.1,<2.0.0" +typing-extensions = {version = ">=4.8.0", markers = "python_version < \"3.11\""} + +[package.extras] +toml = ["tomli (>=2.0.0) ; python_version < \"3.11\""] +trio = ["trio (>=0.10.0)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "decorator" version = "5.2.1" @@ -584,13 +602,31 @@ files = [ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"4.0\"" +files = [ + {file = "docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708"}, + {file = "docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912"}, +] + +[package.extras] +dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"] +docs = ["pydoctor (>=25.4.0)"] +test = ["pytest"] + [[package]] name = "docutils" version = "0.18.1" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, @@ -887,7 +923,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" groups = ["docs"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -905,30 +941,6 @@ perf = ["ipython"] test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] -[[package]] -name = "importlib-resources" -version = "6.4.5" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, - {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.1.0" @@ -1859,19 +1871,6 @@ files = [ [package.dependencies] pytest = ">=7.0.0" -[[package]] -name = "pytz" -version = "2025.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["docs"] -markers = "python_version == \"3.8\"" -files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1993,17 +1992,24 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.1 jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" +name = "rich-rst" +version = "1.3.2" +description = "A beautiful reStructuredText renderer for rich" optional = false -python-versions = ">=3.7" +python-versions = "*" groups = ["main"] files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, + {file = "rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a"}, + {file = "rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4"}, ] +[package.dependencies] +docutils = "*" +rich = ">=12.0.0" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "smmap" version = "5.0.2" @@ -2272,24 +2278,6 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] -[[package]] -name = "typer" -version = "0.16.0" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, - {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - [[package]] name = "typing-extensions" version = "4.13.2" @@ -2472,12 +2460,12 @@ version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" -groups = ["main", "docs"] +groups = ["docs"] +markers = "python_version == \"3.9\"" files = [ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] -markers = {main = "python_version == \"3.8\"", docs = "python_version < \"3.10\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -2489,5 +2477,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" -python-versions = ">=3.8" -content-hash = "2811eed33a649b86c63ef4b225613fcb07c4cacad85ee6f4e8cdd7945d3d5c84" +python-versions = ">=3.9" +content-hash = "3212853bd0708a306dbe68111aefe597bd041c150322c53ad65d55932ea3042f" diff --git a/pyproject.toml b/pyproject.toml index 3249c6c8..1424368e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,9 @@ belay = "belay.cli.main:run_app" [tool.poetry.dependencies] # Be as loose as possible if writing a library. -python = ">=3.8" +python = ">=3.9" pyserial = ">=3.1" -typer = {extras = ["all"], version = ">=0.6.0"} +cyclopts = ">=3.22.0" pathspec = ">=0.10.3" tomli = ">=2.0.1" autoregistry = ">=0.10.1" @@ -36,7 +36,6 @@ pydantic = ">=2.0.0" typing-extensions = ">=4.5.0" questionary = ">=2.0.0" packaging = ">=20.4" -importlib_resources = {version = ">=3.0.0", python = "<3.9"} attrs = ">=21.0.0" [tool.poetry.group.docs.dependencies] @@ -105,7 +104,7 @@ markers = [ ] [tool.ruff] -target-version = 'py38' +target-version = 'py39' line-length = 120 # Must agree with Black exclude = [ "migrations", @@ -164,8 +163,6 @@ select = [ [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ "chr", - "typer.Argument", - "typer.Option", ] [tool.ruff.lint.pydocstyle] @@ -204,6 +201,5 @@ paths=["belay"] deps-file="pyproject.toml" sections=["tool.poetry.dependencies"] exclude-deps =[ - "importlib_resources", "pydantic", ] diff --git a/tests/cli/test_exec.py b/tests/cli/test_exec.py index 0d90b7d6..56b23133 100644 --- a/tests/cli/test_exec.py +++ b/tests/cli/test_exec.py @@ -1,5 +1,10 @@ -def test_exec_basic(mocker, mock_device, cli_runner): +from belay.cli.main import app +from tests.conftest import run_cli + + +def test_exec_basic(mocker, mock_device): mock_device.patch("belay.cli.exec.Device") - result = cli_runner("exec", "print('hello world')") - assert result.exit_code == 0 + exit_code = run_cli(app, ["exec", "/dev/ttyUSB0", "print('hello world')", "--password", "password"]) + assert exit_code == 0 + mock_device.cls_assert_common() mock_device.inst.assert_called_once_with("print('hello world')") diff --git a/tests/cli/test_info.py b/tests/cli/test_info.py index d65dd0da..c79f64cb 100644 --- a/tests/cli/test_info.py +++ b/tests/cli/test_info.py @@ -1,8 +1,14 @@ -def test_info_basic(mocker, mock_device, cli_runner, tmp_path): +from belay.cli.main import app +from tests.conftest import run_cli + + +def test_info_basic(mocker, mock_device, capsys): mock_device.patch("belay.cli.info.Device") mock_device.inst.implementation.name = "testingpython" mock_device.inst.implementation.version = (4, 7, 9) mock_device.inst.implementation.platform = "pytest" - result = cli_runner("info") - assert result.exit_code == 0 - assert result.output == "testingpython v4.7.9 - pytest\n" + exit_code = run_cli(app, ["info", "/dev/ttyUSB0", "--password", "password"]) + assert exit_code == 0 + mock_device.cls_assert_common() + captured = capsys.readouterr() + assert captured.out == "testingpython v4.7.9 - pytest\n" diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 5478a51c..5cf90122 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -1,10 +1,7 @@ from pathlib import Path -from typer.testing import CliRunner - -from belay.cli import app - -cli_runner = CliRunner() +from belay.cli.main import app +from tests.conftest import run_cli def test_install_no_pkg(tmp_path, mocker, mock_device): @@ -15,23 +12,11 @@ def test_install_no_pkg(tmp_path, mocker, mock_device): mock_load_toml = mocker.patch("belay.project.load_toml", return_value=toml) mock_device.patch("belay.cli.install.Device") - result = cli_runner.invoke( - app, - [ - "install", - "/dev/ttyUSB0", - "--password", - "password", - "--run", - str(main_py), - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 + exit_code = run_cli(app, ["install", "/dev/ttyUSB0", "--run", str(main_py), "--password", "password"]) + assert exit_code == 0 mock_load_toml.assert_called_once() - mock_device.cls.assert_called_once_with("/dev/ttyUSB0", password="password") + mock_device.cls_assert_common() mock_device.inst.sync.assert_called_once_with( # Dependencies sync mocker.ANY, progress_update=mocker.ANY, @@ -52,15 +37,11 @@ def test_install_basic(tmp_path, mocker, mock_device): mocker.patch("belay.cli.install.find_project_folder", return_value=Path()) mock_device.patch("belay.cli.install.Device") - result = cli_runner.invoke( - app, - ["install", "/dev/ttyUSB0", "--password", "password"], - catch_exceptions=False, - ) - assert result.exit_code == 0 + exit_code = run_cli(app, ["install", "/dev/ttyUSB0", "--password", "password"]) + assert exit_code == 0 mock_load_toml.assert_called_once() - mock_device.cls.assert_called_once_with("/dev/ttyUSB0", password="password") + mock_device.cls_assert_common() mock_device.inst.sync.assert_has_calls( [ mocker.call( diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 465cc495..ca9270ee 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -1,7 +1,12 @@ -def test_run_basic(mocker, mock_device, cli_runner, tmp_path): +from belay.cli.main import app +from tests.conftest import run_cli + + +def test_run_basic(mocker, mock_device, tmp_path): mock_device.patch("belay.cli.run.Device") py_file = tmp_path / "foo.py" py_file.write_text("print('hello')\nprint('world')") - result = cli_runner("run", str(py_file)) - assert result.exit_code == 0 + exit_code = run_cli(app, ["run", "/dev/ttyUSB0", str(py_file), "--password", "password"]) + assert exit_code == 0 + mock_device.cls_assert_common() mock_device.inst.assert_called_once_with("print('hello')\nprint('world')") diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py index 495b7a64..7f13b6c9 100644 --- a/tests/cli/test_sync.py +++ b/tests/cli/test_sync.py @@ -1,10 +1,14 @@ from pathlib import Path +from belay.cli.main import app +from tests.conftest import run_cli -def test_sync_basic(mocker, mock_device, cli_runner): + +def test_sync_basic(mocker, mock_device): mock_device.patch("belay.cli.sync.Device") - result = cli_runner("sync", "foo") - assert result.exit_code == 0 + exit_code = run_cli(app, ["sync", "/dev/ttyUSB0", "foo", "--password", "password"]) + assert exit_code == 0 + mock_device.cls_assert_common() mock_device.inst.sync.assert_called_once_with( Path("foo"), dst="/", diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py index c9a0a48e..25c2b3d7 100644 --- a/tests/cli/test_update.py +++ b/tests/cli/test_update.py @@ -1,11 +1,8 @@ import os -from typer.testing import CliRunner - -from belay.cli import app +from belay.cli.main import app from belay.packagemanager import Group - -cli_runner = CliRunner() +from tests.conftest import run_cli def test_update(mocker, tmp_path): @@ -17,8 +14,10 @@ def test_update(mocker, tmp_path): groups = [Group("name", dependencies={"foo": "foo.py"})] mock_download = mocker.patch.object(groups[0], "download") mock_load_groups = mocker.patch("belay.cli.update.load_groups", return_value=groups) - res = cli_runner.invoke(app, ["update"]) - assert res.exit_code == 0 + + exit_code = run_cli(app, ["update"]) + assert exit_code == 0 + mock_load_groups.assert_called_once_with() mock_download.assert_called_once_with( packages=None, @@ -35,8 +34,10 @@ def test_update_specific_packages(mocker, tmp_path): groups = [Group("name", dependencies={"foo": "foo.py", "bar": "bar.py", "baz": "baz.py"})] mock_download = mocker.patch.object(groups[0], "download") mock_load_groups = mocker.patch("belay.cli.update.load_groups", return_value=groups) - res = cli_runner.invoke(app, ["update", "bar", "baz"]) - assert res.exit_code == 0 + + exit_code = run_cli(app, ["update", "bar", "baz"]) + assert exit_code == 0 + mock_load_groups.assert_called_once_with() mock_download.assert_called_once_with( packages=["bar", "baz"], diff --git a/tests/conftest.py b/tests/conftest.py index 7c1c14b2..a3539f5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,15 +5,40 @@ from pathlib import Path import pytest -from typer.testing import CliRunner import belay import belay.cli.common import belay.project -from belay.cli import app from belay.utils import env_parse_bool +def run_cli(app, args): + """Run a CLI app with support for both Cyclopts v3 and v4. + + Cyclopts v3 returns None on success. + Cyclopts v4 raises SystemExit with code 0 on success. + + Parameters + ---------- + app : callable + The CLI app to run. + args : list + Command line arguments. + + Returns + ------- + int + Exit code (0 for success). + """ + try: + result = app(args) + # v3 behavior: returns None or int + return result if isinstance(result, int) else 0 + except SystemExit as e: + # v4 behavior: raises SystemExit + return e.code if e.code is not None else 0 + + class MockDevice: def __init__(self, mocker): self.mocker = mocker @@ -53,18 +78,6 @@ def mock_device(mocker): return MockDevice(mocker) -@pytest.fixture -def cli_runner(mock_device): - cli_runner = CliRunner() - - def run(cmd, *args): - result = cli_runner.invoke(app, [cmd, "/dev/ttyUSB0", *args, "--password", "password"]) - mock_device.cls_assert_common() - return result - - return run - - @pytest.fixture( params=( [ diff --git a/tools/update-fnv1a32.py b/tools/update-fnv1a32.py index 6ac57a21..b5c0b756 100644 --- a/tools/update-fnv1a32.py +++ b/tools/update-fnv1a32.py @@ -9,11 +9,10 @@ import shutil import tempfile from pathlib import Path -from typing import Dict, List from urllib.request import urlopen, urlretrieve -def get_release_assets(version: str) -> List[Dict]: +def get_release_assets(version: str) -> list[dict]: """Get release assets from GitHub API.""" if not version.startswith("v"): version = f"v{version}" @@ -31,7 +30,7 @@ def get_release_assets(version: str) -> List[Dict]: return [] -def download_and_extract_mpy_files(assets: List[Dict], temp_dir: Path) -> List[Path]: +def download_and_extract_mpy_files(assets: list[dict], temp_dir: Path) -> list[Path]: """Download and extract .mpy files from release assets.""" mpy_files = [] @@ -64,7 +63,7 @@ def convert_filename(original_name: str) -> str: return original_name -def copy_files_to_destination(mpy_files: List[Path], dest_dir: Path): +def copy_files_to_destination(mpy_files: list[Path], dest_dir: Path): """Copy .mpy files to the destination directory with proper naming.""" dest_dir.mkdir(parents=True, exist_ok=True)