Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions belay/cli/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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():
Expand All @@ -52,7 +61,7 @@ def clear(
shutil.rmtree(path)


@app.command()
@app.command
def list():
"""List cache elements."""
cache_folder = find_cache_folder()
Expand All @@ -62,7 +71,7 @@ def list():
print(item)


@app.command()
@app.command
def info():
"""Display cache location and size."""
cache_folder = find_cache_folder()
Expand Down
34 changes: 22 additions & 12 deletions belay/cli/common.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 9 additions & 11 deletions belay/cli/exec.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 2 additions & 7 deletions belay/cli/info.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
40 changes: 28 additions & 12 deletions belay/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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()
83 changes: 30 additions & 53 deletions belay/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
16 changes: 8 additions & 8 deletions belay/cli/new.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading