diff --git a/circfirm/__init__.py b/circfirm/__init__.py index b3b4105..11a5775 100644 --- a/circfirm/__init__.py +++ b/circfirm/__init__.py @@ -7,11 +7,9 @@ Author(s): Alec Delaney """ - from circfirm.startup import setup_app_dir, setup_file, setup_folder # Folders - APP_DIR = setup_app_dir("circfirm") UF2_ARCHIVE = setup_folder(APP_DIR, "archive") diff --git a/circfirm/cli.py b/circfirm/cli/__init__.py similarity index 51% rename from circfirm/cli.py rename to circfirm/cli/__init__.py index ec9d64c..05cac9c 100644 --- a/circfirm/cli.py +++ b/circfirm/cli/__init__.py @@ -7,8 +7,9 @@ Author(s): Alec Delaney """ +import importlib.util import os -import pathlib +import pkgutil import shutil import sys import time @@ -24,7 +25,7 @@ @click.group() @click.version_option(package_name="circfirm") def cli() -> None: - """Install CircuitPython firmware from the command line.""" + """Manage CircuitPython firmware from the command line.""" circfirm.startup.ensure_app_setup() @@ -44,6 +45,31 @@ def announce_and_await( return resp +def load_subcmd_folder(path: str, super_import_name: str) -> None: + """Load subcommands dynamically from a folder of modules and packages.""" + subcmd_names = [ + (modname, ispkg) for _, modname, ispkg in pkgutil.iter_modules((path,)) + ] + subcmd_paths = [ + os.path.abspath(os.path.join(path, subcmd_name[0])) + for subcmd_name in subcmd_names + ] + + for (subcmd_name, ispkg), subcmd_path in zip(subcmd_names, subcmd_paths): + import_name = ".".join([super_import_name, subcmd_name]) + import_path = subcmd_path if ispkg else subcmd_path + ".py" + module_spec = importlib.util.spec_from_file_location(import_name, import_path) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + source_cli: click.MultiCommand = getattr(module, "cli") + if isinstance(source_cli, click.Group): + subcmd = click.CommandCollection(sources=(source_cli,)) + subcmd.help = source_cli.__doc__ + else: + subcmd = source_cli + cli.add_command(subcmd, subcmd_name) + + @cli.command() @click.argument("version") @click.option("-l", "--language", default="en_US", help="CircuitPython language/locale") @@ -99,78 +125,7 @@ def install(version: str, language: str, board: Optional[str]) -> None: click.echo("Device should reboot momentarily.") -@cli.group() -def cache(): - """Work with cached information.""" - - -@cache.command() -@click.option("-b", "--board", default=None, help="CircuitPythonoard name") -@click.option("-v", "--version", default=None, help="CircuitPython version") -@click.option("-l", "--language", default=None, help="CircuitPython language/locale") -def clear( - board: Optional[str], version: Optional[str], language: Optional[str] -) -> None: - """Clear the cache, either entirely or for a specific board/version.""" - if board is None and version is None and language is None: - shutil.rmtree(circfirm.UF2_ARCHIVE) - circfirm.startup.ensure_app_setup() - click.echo("Cache cleared!") - return - - glob_pattern = "*-*" if board is None else f"*-{board}" - language_pattern = "-*" if language is None else f"-{language}" - glob_pattern += language_pattern - version_pattern = "-*" if version is None else f"-{version}*" - glob_pattern += version_pattern - matching_files = pathlib.Path(circfirm.UF2_ARCHIVE).rglob(glob_pattern) - for matching_file in matching_files: - matching_file.unlink() - - # Delete board folder if empty - for board_folder in pathlib.Path(circfirm.UF2_ARCHIVE).glob("*"): - if len(os.listdir(board_folder)) == 0: - shutil.rmtree(board_folder) - - click.echo("Cache cleared of specified entries!") - - -@cache.command(name="list") -@click.option("-b", "--board", default=None, help="CircuitPython board name") -def cache_list(board: Optional[str]) -> None: - """List all the boards/versions cached.""" - board_list = os.listdir(circfirm.UF2_ARCHIVE) - - if not board_list: - click.echo("Versions have not been cached yet for any boards.") - sys.exit(0) - - if board is not None and board not in board_list: - click.echo(f"No versions for board '{board}' are not cached.") - sys.exit(0) - - specified_board = board if board is not None else None - boards = circfirm.backend.get_sorted_boards(specified_board) - - for rec_boardname, rec_boardvers in boards.items(): - click.echo(f"{rec_boardname}") - for rec_boardver, rec_boardlangs in rec_boardvers.items(): - for rec_boardlang in rec_boardlangs: - click.echo(f" * {rec_boardver} ({rec_boardlang})") - - -@cache.command(name="save") -@click.argument("board") -@click.argument("version") -@click.option("-l", "--language", default="en_US", help="CircuitPython language/locale") -def cache_save(board: str, version: str, language: str) -> None: - """Install a version of CircuitPython to cache.""" - try: - announce_and_await( - f"Caching firmware version {version} for {board}", - circfirm.backend.download_uf2, - args=(board, version, language), - ) - except ConnectionError as err: - click.echo(" failed") # Mark as failed - raise click.exceptions.ClickException(err.args[0]) +# Load extra commands from the rest of the circfirm.cli subpackage +cli_pkg_path = os.path.dirname(os.path.abspath(__file__)) +cli_pkg_name = "circfirm.cli" +load_subcmd_folder(cli_pkg_path, cli_pkg_name) diff --git a/circfirm/cli/about.py b/circfirm/cli/about.py new file mode 100644 index 0000000..ba99460 --- /dev/null +++ b/circfirm/cli/about.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""CLI functionality for the about subcommand. + +Author(s): Alec Delaney +""" + +import click + + +@click.command() +def cli() -> None: + """Information about circfirm.""" + click.echo("Written by Alec Delaney, licensed under MIT License.") diff --git a/circfirm/cli/cache.py b/circfirm/cli/cache.py new file mode 100644 index 0000000..e79e30c --- /dev/null +++ b/circfirm/cli/cache.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""CLI functionality for the cache subcommand. + +Author(s): Alec Delaney +""" + +import os +import pathlib +import shutil +import sys +from typing import Optional + +import click + +import circfirm +import circfirm.backend +import circfirm.cli +import circfirm.startup + + +@click.group() +def cli(): + """Work with cached firmwares.""" + + +@cli.command() +@click.option("-b", "--board", default=None, help="CircuitPythonoard name") +@click.option("-v", "--version", default=None, help="CircuitPython version") +@click.option("-l", "--language", default=None, help="CircuitPython language/locale") +def clear( + board: Optional[str], version: Optional[str], language: Optional[str] +) -> None: + """Clear the cache, either entirely or for a specific board/version.""" + if board is None and version is None and language is None: + shutil.rmtree(circfirm.UF2_ARCHIVE) + circfirm.startup.ensure_app_setup() + click.echo("Cache cleared!") + return + + glob_pattern = "*-*" if board is None else f"*-{board}" + language_pattern = "-*" if language is None else f"-{language}" + glob_pattern += language_pattern + version_pattern = "-*" if version is None else f"-{version}*" + glob_pattern += version_pattern + matching_files = pathlib.Path(circfirm.UF2_ARCHIVE).rglob(glob_pattern) + for matching_file in matching_files: + matching_file.unlink() + + # Delete board folder if empty + for board_folder in pathlib.Path(circfirm.UF2_ARCHIVE).glob("*"): + if len(os.listdir(board_folder)) == 0: + shutil.rmtree(board_folder) + + click.echo("Cache cleared of specified entries!") + + +@cli.command(name="list") +@click.option("-b", "--board", default=None, help="CircuitPython board name") +def cache_list(board: Optional[str]) -> None: + """List all the boards/versions cached.""" + board_list = os.listdir(circfirm.UF2_ARCHIVE) + + if not board_list: + click.echo("Versions have not been cached yet for any boards.") + sys.exit(0) + + if board is not None and board not in board_list: + click.echo(f"No versions for board '{board}' are not cached.") + sys.exit(0) + + specified_board = board if board is not None else None + boards = circfirm.backend.get_sorted_boards(specified_board) + + for rec_boardname, rec_boardvers in boards.items(): + click.echo(f"{rec_boardname}") + for rec_boardver, rec_boardlangs in rec_boardvers.items(): + for rec_boardlang in rec_boardlangs: + click.echo(f" * {rec_boardver} ({rec_boardlang})") + + +@cli.command(name="save") +@click.argument("board") +@click.argument("version") +@click.option("-l", "--language", default="en_US", help="CircuitPython language/locale") +def cache_save(board: str, version: str, language: str) -> None: + """Install a version of CircuitPython to cache.""" + try: + circfirm.cli.announce_and_await( + f"Caching firmware version {version} for {board}", + circfirm.backend.download_uf2, + args=(board, version, language), + ) + except ConnectionError as err: + click.echo(" failed") # Mark as failed + raise click.exceptions.ClickException(err.args[0]) diff --git a/circfirm/startup.py b/circfirm/startup.py index d1612ad..5bd2a8f 100644 --- a/circfirm/startup.py +++ b/circfirm/startup.py @@ -19,17 +19,13 @@ def setup_app_dir(app_name: str) -> str: """Set up the application directory.""" app_path = click.get_app_dir(app_name) - FOLDER_LIST.append(app_name) + FOLDER_LIST.append(app_path) return app_path def setup_folder(*path_parts: str) -> str: """Add a folder to the global record list.""" - folder_path = ( - click.get_app_dir("circfirm") - if len(path_parts) == 1 - else os.path.join(path_parts[0], *path_parts[1:]) - ) + folder_path = os.path.join(*path_parts) FOLDER_LIST.append(folder_path) return folder_path diff --git a/pyproject.toml b/pyproject.toml index 12f293a..a626ec4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ Issues = "https://github.com/tekktrik/circfirm/issues" circfirm = "circfirm.cli:cli" [tool.setuptools] -packages = ["circfirm"] +packages = ["circfirm", "circfirm.cli"] include-package-data = true [tool.setuptools.dynamic.version] @@ -67,6 +67,8 @@ file = "VERSION" dependencies = {file = ["requirements.txt"]} optional-dependencies = {dev = {file = ["requirements-dev.txt"]}} +[tool.setuptools_scm] + [tool.coverage.run] source = [ "circfirm/", diff --git a/tests/cli/test_cli_about.py b/tests/cli/test_cli_about.py new file mode 100644 index 0000000..6e6e7a1 --- /dev/null +++ b/tests/cli/test_cli_about.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""Tests the CLI functionality for about command. + +Author(s): Alec Delaney +""" + +from click.testing import CliRunner + +from circfirm.cli import cli + + +def test_about() -> None: + """Tests the about command.""" + runner = CliRunner() + + result = runner.invoke(cli, ["about"]) + assert result.exit_code == 0 + assert result.output == "Written by Alec Delaney, licensed under MIT License.\n" diff --git a/tests/test_cli.py b/tests/cli/test_cli_cache.py similarity index 64% rename from tests/test_cli.py rename to tests/cli/test_cli_cache.py index 237884d..0d79484 100644 --- a/tests/test_cli.py +++ b/tests/cli/test_cli_cache.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""Tests the CLI functionality. +"""Tests the CLI functionality for cache command. Author(s): Alec Delaney """ @@ -10,78 +10,14 @@ import os import pathlib import shutil -import threading -import time from click.testing import CliRunner -import circfirm.backend +import circfirm import tests.helpers from circfirm.cli import cli -def test_install() -> None: - """Tests the install command.""" - - def wait_and_add() -> None: - """Wait then add the boot_out.txt file.""" - time.sleep(2) - tests.helpers.delete_mount_node(circfirm.BOOTOUT_FILE) - tests.helpers.copy_uf2_info() - - version = "8.0.0-beta.6" - runner = CliRunner() - - # Test successfully installing the firmware - tests.helpers.delete_mount_node(circfirm.UF2INFO_FILE) - tests.helpers.copy_boot_out() - threading.Thread(target=wait_and_add).start() - result = runner.invoke(cli, ["install", version]) - assert result.exit_code == 0 - expected_uf2_filename = circfirm.backend.get_uf2_filename( - "feather_m4_express", version - ) - expected_uf2_filepath = tests.helpers.get_mount_node(expected_uf2_filename) - assert os.path.exists(expected_uf2_filepath) - os.remove(expected_uf2_filepath) - - # Test using cached version of firmware - result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) - assert result.exit_code == 0 - assert "Using cached firmware file" in result.output - os.remove(expected_uf2_filepath) - - ERR_NOT_FOUND = 1 - ERR_FOUND_CIRCUITPY = 2 - ERR_IN_BOOTLOADER = 3 - ERR_UF2_DOWNLOAD = 4 - - # Test not finding the mounted drive - tests.helpers.delete_mount_node(circfirm.UF2INFO_FILE) - result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) - assert result.exit_code == ERR_NOT_FOUND - - # Test finding the mounted drive as CIRCUITPY - tests.helpers.copy_boot_out() - result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) - assert result.exit_code == ERR_FOUND_CIRCUITPY - - # Test using bad board version - tests.helpers.delete_mount_node(circfirm.BOOTOUT_FILE) - tests.helpers.copy_uf2_info() - result = runner.invoke( - cli, ["install", "doesnotexist", "--board", "feather_m4_express"] - ) - assert result.exit_code == ERR_UF2_DOWNLOAD - - # Test using install when in bootloader mode - result = runner.invoke(cli, ["install", version]) - assert result.exit_code == ERR_IN_BOOTLOADER - - board_folder = circfirm.backend.get_board_folder("feather_m4_express") - shutil.rmtree(board_folder) - - def test_cache_list() -> None: """Tests the cache list command.""" runner = CliRunner() diff --git a/tests/cli/test_cli_install.py b/tests/cli/test_cli_install.py new file mode 100644 index 0000000..ec5c768 --- /dev/null +++ b/tests/cli/test_cli_install.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""Tests the CLI functionality for install command. + +Author(s): Alec Delaney +""" + +import os +import pathlib +import shutil +import threading +import time + +from click.testing import CliRunner + +import circfirm.backend +import tests.helpers +from circfirm.cli import cli + + +def test_install() -> None: + """Tests the install command.""" + + def wait_and_add() -> None: + """Wait then add the boot_out.txt file.""" + time.sleep(2) + tests.helpers.delete_mount_node(circfirm.BOOTOUT_FILE) + tests.helpers.copy_uf2_info() + + version = "8.0.0-beta.6" + runner = CliRunner() + + # Test successfully installing the firmware + tests.helpers.delete_mount_node(circfirm.UF2INFO_FILE) + tests.helpers.copy_boot_out() + threading.Thread(target=wait_and_add).start() + result = runner.invoke(cli, ["install", version]) + assert result.exit_code == 0 + expected_uf2_filename = circfirm.backend.get_uf2_filename( + "feather_m4_express", version + ) + expected_uf2_filepath = tests.helpers.get_mount_node(expected_uf2_filename) + assert os.path.exists(expected_uf2_filepath) + os.remove(expected_uf2_filepath) + + # Test using cached version of firmware + result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) + assert result.exit_code == 0 + assert "Using cached firmware file" in result.output + os.remove(expected_uf2_filepath) + + ERR_NOT_FOUND = 1 + ERR_FOUND_CIRCUITPY = 2 + ERR_IN_BOOTLOADER = 3 + ERR_UF2_DOWNLOAD = 4 + + # Test not finding the mounted drive + tests.helpers.delete_mount_node(circfirm.UF2INFO_FILE) + result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) + assert result.exit_code == ERR_NOT_FOUND + + # Test finding the mounted drive as CIRCUITPY + tests.helpers.copy_boot_out() + result = runner.invoke(cli, ["install", version, "--board", "feather_m4_express"]) + assert result.exit_code == ERR_FOUND_CIRCUITPY + + # Test using bad board version + tests.helpers.delete_mount_node(circfirm.BOOTOUT_FILE) + tests.helpers.copy_uf2_info() + result = runner.invoke( + cli, ["install", "doesnotexist", "--board", "feather_m4_express"] + ) + assert result.exit_code == ERR_UF2_DOWNLOAD + + # Test using install when in bootloader mode + result = runner.invoke(cli, ["install", version]) + assert result.exit_code == ERR_IN_BOOTLOADER + + board_folder = circfirm.backend.get_board_folder("feather_m4_express") + shutil.rmtree(board_folder)