diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 76a05df..71c42f1 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -6,6 +6,8 @@ name: Build CI on: ["push", "pull_request"] +permissions: read-all + jobs: build: name: Run build CI @@ -45,6 +47,8 @@ jobs: - name: Run tests run: | make test-run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Clean up after tests run: | make test-clean diff --git a/.gitignore b/.gitignore index fe06630..afc3cef 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ testmount/ # Test related tests/backup/ +tests/sandbox/ diff --git a/Makefile b/Makefile index baec6ce..bb1008a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MIT - .PHONY: lint lint: @pre-commit run ruff --all-files @@ -35,6 +34,7 @@ else @echo "Current OS not supported" @exit 1 endif + -@git clone https://github.com/adafruit/circuitpython tests/sandbox/circuitpython --depth 1 .PHONY: test test: @@ -53,11 +53,14 @@ test-clean: ifeq "$(OS)" "Windows_NT" -@subst T: /d -@python scripts/rmdir.py testmount + -@python scripts/rmdir.py tests/sandbox/circuitpython else ifeq "$(shell uname -s)" "Linux" -@sudo umount testmount -@sudo rm -rf testmount -@rm testfs -f + -@rm -rf tests/sandbox/circuitpython else -@hdiutil detach /Volumes/TESTMOUNT -@rm testfs.dmg -f + -@rm -rf tests/sandbox/circuitpython endif diff --git a/circfirm/__init__.py b/circfirm/__init__.py index 11a5775..301289b 100644 --- a/circfirm/__init__.py +++ b/circfirm/__init__.py @@ -7,14 +7,27 @@ Author(s): Alec Delaney """ -from circfirm.startup import setup_app_dir, setup_file, setup_folder +import os + +from circfirm.startup import ( + specify_app_dir, + specify_file, + specify_folder, + specify_template, +) # Folders -APP_DIR = setup_app_dir("circfirm") -UF2_ARCHIVE = setup_folder(APP_DIR, "archive") +APP_DIR = specify_app_dir("circfirm") +UF2_ARCHIVE = specify_folder(APP_DIR, "archive") # Files -SETTINGS_FILE = setup_file(APP_DIR, "settings.yaml") +_SETTINGS_FILE_SRC = os.path.abspath( + os.path.join(__file__, "..", "templates", "settings.yaml") +) +SETTINGS_FILE = specify_template( + _SETTINGS_FILE_SRC, os.path.join(APP_DIR, "settings.yaml") +) +UF2_BOARD_LIST = specify_file(APP_DIR, "boards.txt") UF2INFO_FILE = "info_uf2.txt" BOOTOUT_FILE = "boot_out.txt" diff --git a/circfirm/backend.py b/circfirm/backend.py index 0af2453..22208a0 100644 --- a/circfirm/backend.py +++ b/circfirm/backend.py @@ -7,15 +7,20 @@ Author(s): Alec Delaney """ +import datetime import enum import os import pathlib import re -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple, TypedDict +import boto3 +import botocore +import botocore.client import packaging.version import psutil import requests +from mypy_boto3_s3 import S3ServiceResource import circfirm import circfirm.startup @@ -48,15 +53,57 @@ class Language(enum.Enum): _ALL_LANGAGES = [language.value for language in Language] _ALL_LANGUAGES_REGEX = "|".join(_ALL_LANGAGES) -FIRMWARE_REGEX = "-".join( +_VALID_VERSIONS_CAPTURE = r"(\d+\.\d+\.\d+(?:-(?:\balpha\b|\bbeta\b)\.\d+)*)" +FIRMWARE_REGEX_PATTERN = "-".join( [ - r"adafruit-circuitpython-(.*)", - f"({_ALL_LANGUAGES_REGEX})", - r"(\d+\.\d+\.\d+(?:-(?:\balpha\b|\bbeta\b)\.\d+)*)\.uf2", + r"adafruit-circuitpython", + r"[board]", + r"[language]", + r"[version]\.uf2", ] ) +FIRMWARE_REGEX = ( + FIRMWARE_REGEX_PATTERN.replace(r"[board]", r"(.*)") + .replace(r"[language]", f"({_ALL_LANGUAGES_REGEX})") + .replace(r"[version]", _VALID_VERSIONS_CAPTURE) +) + BOARD_ID_REGEX = r"Board ID:\s*(.*)" +S3_CONFIG = botocore.client.Config(signature_version=botocore.UNSIGNED) +S3_RESOURCE: S3ServiceResource = boto3.resource("s3", config=S3_CONFIG) +BUCKET_NAME = "adafruit-circuit-python" +BUCKET = S3_RESOURCE.Bucket(BUCKET_NAME) + +BOARDS_REGEX = r"ports/.+/boards/([^/]+)" +BOARDS_REGEX_PATTERN2 = r"bin/([board_pattern])/en_US/.*\.uf2" + +_BASE_REQUESTS_HEADERS = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + + +class RateLimit(TypedDict): + """Format of a rate limit dictionary.""" + + limit: int + remaining: int + reset: int + used: int + resource: str + + +class GitTreeItem(TypedDict): + """Format of a git tree item dictionary.""" + + path: str + mode: str + type: str + size: int + sha: str + url: str + def _find_device(filename: str) -> Optional[str]: """Find a specific connected device.""" @@ -102,7 +149,7 @@ def download_uf2(board: str, version: str, language: str = "en_US") -> None: if response.status_code != SUCCESS: if not list(uf2_file.parent.glob("*")): uf2_file.parent.rmdir() - raise ConnectionError(f"Could not download spectified UF2 file:\n{url}") + raise ConnectionError(f"Could not download the specified UF2 file:\n{url}") with open(uf2_file, mode="wb") as uf2file: uf2file.write(response.content) @@ -170,3 +217,65 @@ def get_sorted_boards(board: Optional[str]) -> Dict[str, Dict[str, Set[str]]]: sorted_versions[sorted_version] = versions[sorted_version] boards[board_folder] = sorted_versions return boards + + +def get_rate_limit() -> Tuple[int, int, datetime.datetime]: + """Get the rate limit for the GitHub REST endpoint.""" + response = requests.get( + url="https://api.github.com/rate_limit", + headers=_BASE_REQUESTS_HEADERS, + ) + limit_info: RateLimit = response.json()["rate"] + available: int = limit_info["remaining"] + total: int = limit_info["limit"] + reset_time = datetime.datetime.fromtimestamp(limit_info["reset"]) + return available, total, reset_time + + +def get_board_list(token: str) -> List[str]: + """Get a list of CircuitPython boards.""" + boards = set() + headers = _BASE_REQUESTS_HEADERS.copy() + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.get( + url="https://api.github.com/repos/adafruit/circuitpython/git/trees/main", + params={ + "recursive": True, + }, + headers=headers, + ) + try: + tree_items: List[GitTreeItem] = response.json()["tree"] + except KeyError as err: + raise ValueError("Could not parse JSON response, check token") from err + for tree_item in tree_items: + if tree_item["type"] != "tree": + continue + result = re.match(BOARDS_REGEX, tree_item["path"]) + if result: + boards.add(result[1]) + return sorted(boards) + + +def get_board_versions( + board: str, language: str = "en_US", *, regex: Optional[str] = None +) -> List[str]: + """Get a list of CircuitPython versions for a given board.""" + prefix = f"bin/{board}/{language}" + firmware_regex = FIRMWARE_REGEX_PATTERN.replace(r"[board]", board).replace( + r"[language]", language + ) + version_regex = f"({regex})" if regex else _VALID_VERSIONS_CAPTURE + firmware_regex = firmware_regex.replace(r"[version]", version_regex) + s3_objects = BUCKET.objects.filter(Prefix=prefix) + versions = set() + for s3_object in s3_objects: + result = re.match(f"{prefix}/{firmware_regex}", s3_object.key) + if result: + try: + _ = packaging.version.Version(result[1]) + versions.add(result[1]) + except packaging.version.InvalidVersion: + pass + return sorted(versions, key=packaging.version.Version, reverse=True) diff --git a/circfirm/cli/__init__.py b/circfirm/cli/__init__.py index 05cac9c..f6bf30e 100644 --- a/circfirm/cli/__init__.py +++ b/circfirm/cli/__init__.py @@ -13,14 +13,18 @@ import shutil import sys import time -from typing import Any, Callable, Dict, Iterable, Optional +from typing import Any, Callable, Dict, Iterable, Optional, TypeVar import click +import click_spinner +import yaml import circfirm import circfirm.backend import circfirm.startup +_T = TypeVar("_T") + @click.group() @click.version_option(package_name="circfirm") @@ -29,20 +33,47 @@ def cli() -> None: circfirm.startup.ensure_app_setup() +def maybe_support(msg: str) -> None: + """Output supporting text based on the configurable settings.""" + settings = get_settings() + do_output: bool = not settings["output"]["supporting"]["silence"] + if do_output: + click.echo(msg) + + def announce_and_await( msg: str, - func: Callable, + func: Callable[..., _T], args: Iterable = (), kwargs: Optional[Dict[str, Any]] = None, -) -> Any: + *, + use_spinner: bool = True, +) -> _T: """Announce an action to be performed, do it, then announce its completion.""" if kwargs is None: kwargs = {} fmt_msg = f"{msg}..." + spinner = click_spinner.spinner() click.echo(fmt_msg, nl=False) - resp = func(*args, **kwargs) - click.echo(" done") - return resp + if use_spinner: + spinner.start() + try: + try: + resp = func(*args, **kwargs) + finally: + if use_spinner: + spinner.stop() + click.echo(" done") + return resp + except BaseException as err: + click.echo(" failed") + raise err + + +def get_settings() -> Dict[str, Any]: + """Get the contents of the settings file.""" + with open(circfirm.SETTINGS_FILE, encoding="utf-8") as yamlfile: + return yaml.safe_load(yamlfile) def load_subcmd_folder(path: str, super_import_name: str) -> None: @@ -110,7 +141,6 @@ def install(version: str, language: str, board: Optional[str]) -> None: args=(board, version, language), ) except ConnectionError as err: - click.echo(" failed") # Mark as failed click.echo(f"Error: {err.args[0]}") sys.exit(4) else: diff --git a/circfirm/cli/cache.py b/circfirm/cli/cache.py index e79e30c..d675bb0 100644 --- a/circfirm/cli/cache.py +++ b/circfirm/cli/cache.py @@ -43,7 +43,7 @@ def clear( 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}*" + version_pattern = "-*" if version is None else f"-{version}.uf2" glob_pattern += version_pattern matching_files = pathlib.Path(circfirm.UF2_ARCHIVE).rglob(glob_pattern) for matching_file in matching_files: @@ -64,11 +64,11 @@ def cache_list(board: Optional[str]) -> None: board_list = os.listdir(circfirm.UF2_ARCHIVE) if not board_list: - click.echo("Versions have not been cached yet for any boards.") + circfirm.cli.maybe_support("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.") + circfirm.cli.maybe_support(f"No versions for board '{board}' are not cached.") sys.exit(0) specified_board = board if board is not None else None @@ -94,5 +94,4 @@ def cache_save(board: str, version: str, language: str) -> None: 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/cli/config.py b/circfirm/cli/config.py new file mode 100644 index 0000000..a50f468 --- /dev/null +++ b/circfirm/cli/config.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# Based on code from circlink (Authored by Alec Delaney, licensed under MIT license) +# +# SPDX-License-Identifier: MIT + +"""CLI functionality for the config subcommand. + +Author(s): Alec Delaney +""" + +import json +import os + +import click +import yaml + +import circfirm +import circfirm.cli +import circfirm.startup + + +@click.group() +def cli(): + """View and update the configuration settings for the circfirm CLI.""" + + +@cli.command(name="view") +@click.argument("setting", default="all") +def config_view(setting: str) -> None: + """View a config setting.""" + # Get the settings, show all settings if no specific on is specified + settings = circfirm.cli.get_settings() + if setting == "all": + click.echo(json.dumps(settings, indent=4)) + return + + # Get the specified settings + config_args = setting.split(".") + try: + for extra_arg in config_args[:-1]: + settings = settings[extra_arg] + value = settings[config_args[-1]] + except KeyError: + raise click.ClickException(f"Setting {setting} does not exist") + + # Show the specified setting + click.echo(json.dumps(value, indent=4)) + + +@cli.command(name="edit") +@click.argument("setting") +@click.argument("value") +def config_edit( + setting: str, + value: str, +) -> None: + """Update a config setting.""" + # Get the settings, use another reference to parse + orig_settings = circfirm.cli.get_settings() + target_setting = orig_settings + config_args = setting.split(".") + + # Handle bool conversions + if value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + + # Attempt to parse for the specified config setting and set it + try: + for extra_arg in config_args[:-1]: + target_setting = target_setting[extra_arg] + prev_value = target_setting[config_args[-1]] + prev_value_type = type(prev_value) + if prev_value_type == dict: + raise ValueError + if prev_value_type == bool and value not in (True, False): + raise TypeError + target_setting[config_args[-1]] = prev_value_type(value) + except KeyError: + raise click.ClickException(f"Setting {setting} does not exist") + except TypeError: + raise click.ClickException( + f"Cannot use that value for this setting, must be of type {prev_value_type}" + ) + except ValueError: + raise click.ClickException( + "Cannot change this setting, please change the sub-settings within it" + ) + + # Write the settings back to the file + with open(circfirm.SETTINGS_FILE, mode="w", encoding="utf-8") as yamlfile: + yaml.safe_dump(orig_settings, yamlfile) + + +@cli.command(name="reset") +def config_reset() -> None: + """Reset the configuration file with the provided template.""" + os.remove(circfirm.SETTINGS_FILE) + src, dest = [ + (src, dest) + for src, dest in circfirm.startup.TEMPLATE_LIST + if dest == circfirm.SETTINGS_FILE + ][0] + circfirm.startup.ensure_template(src, dest) diff --git a/circfirm/cli/query.py b/circfirm/cli/query.py new file mode 100644 index 0000000..b8b5098 --- /dev/null +++ b/circfirm/cli/query.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""CLI functionality for the query subcommand. + +Author(s): Alec Delaney +""" + +import re + +import click +import packaging.version +import requests + +import circfirm +import circfirm.backend +import circfirm.cli + + +@click.group() +def cli(): + """Query things like latest versions and board lists.""" + + +@cli.command(name="boards") +@click.option( + "-r", "--regex", default=".*", help="Regex pattern to use for board names" +) +def query_boards(regex: str) -> None: + """Query the local CircuitPython board list.""" + settings = circfirm.cli.get_settings() + gh_token = settings["token"]["github"] + do_output = not settings["output"]["supporting"]["silence"] + circfirm.cli.maybe_support( + "Boards list will now be synchronized with the git repository." + ) + if not gh_token: + circfirm.cli.maybe_support( + "Please note that this operation can only be performed 60 times per hour due to GitHub rate limiting." + ) + try: + if do_output: + boards = circfirm.cli.announce_and_await( + "Fetching boards list", + circfirm.backend.get_board_list, + args=(gh_token,), + ) + else: + boards = circfirm.backend.get_board_list(gh_token) + except ValueError as err: + raise click.ClickException(err.args[0]) + except requests.ConnectionError as err: + raise click.ClickException( + "Issue with requesting information from git repository, check network connection" + ) + for board in boards: + board_name = board.strip() + result = re.match(regex, board_name) + if result: + click.echo(board_name) + + +@cli.command(name="versions") +@click.argument("board") +@click.option("-l", "--language", default="en_US", help="CircuitPython language/locale") +@click.option("-r", "--regex", default=".*", help="Regex pattern to use for versions") +def query_versions(board: str, language: str, regex: str) -> None: + """Query the CircuitPython versions available for a board.""" + versions = circfirm.backend.get_board_versions(board, language, regex=regex) + for version in reversed(versions): + click.echo(version) + + +@cli.command(name="latest") +@click.argument("board", default="raspberry_pi_pico") +@click.option("-l", "--language", default="en_US", help="CircuitPython language/locale") +@click.option( + "-p", + "--pre-release", + is_flag=True, + default=False, + help="Consider pre-release versions", +) +def query_latest(board: str, language: str, pre_release: bool) -> None: + """Query the latest CircuitPython versions available.""" + versions = circfirm.backend.get_board_versions(board, language) + if not pre_release: + versions = [ + version + for version in versions + if not packaging.version.Version(version).is_prerelease + ] + if versions: + click.echo(versions[0]) diff --git a/circfirm/startup.py b/circfirm/startup.py index 5bd2a8f..04b45e2 100644 --- a/circfirm/startup.py +++ b/circfirm/startup.py @@ -9,34 +9,43 @@ import os import pathlib +import shutil +from typing import List, Tuple import click -FOLDER_LIST = [] -FILE_LIST = [] +FOLDER_LIST: List[str] = [] +FILE_LIST: List[str] = [] +TEMPLATE_LIST: List[Tuple[str, str]] = [] -def setup_app_dir(app_name: str) -> str: +def specify_app_dir(app_name: str) -> str: """Set up the application directory.""" app_path = click.get_app_dir(app_name) FOLDER_LIST.append(app_path) return app_path -def setup_folder(*path_parts: str) -> str: +def specify_folder(*path_parts: str) -> str: """Add a folder to the global record list.""" folder_path = os.path.join(*path_parts) FOLDER_LIST.append(folder_path) return folder_path -def setup_file(*path_parts: str) -> str: +def specify_file(*path_parts: str) -> str: """Add a file to the global record list.""" file_path = os.path.join(*path_parts) FILE_LIST.append(file_path) return file_path +def specify_template(src_path: str, dest_path: str) -> str: + """Add a template to the global record list.""" + TEMPLATE_LIST.append((src_path, dest_path)) + return dest_path + + def ensure_dir(dir_path: str, /) -> None: """Ensure the directory exists, create if needed.""" if not os.path.exists(dir_path): @@ -49,9 +58,17 @@ def _ensure_file(file_path: str, /) -> None: pathlib.Path(file_path).touch(exist_ok=True) +def ensure_template(src_path: str, dest_path: str, /) -> None: + """Ensure the template exists, copy it if needed.""" + if not os.path.exists(dest_path): + shutil.copyfile(src_path, dest_path) + + def ensure_app_setup() -> None: """Ensure the entire application folder is set up.""" for folder in FOLDER_LIST: ensure_dir(folder) for file in FILE_LIST: _ensure_file(file) + for template in TEMPLATE_LIST: + ensure_template(*template) diff --git a/circfirm/templates/settings.yaml b/circfirm/templates/settings.yaml new file mode 100644 index 0000000..1a90cdf --- /dev/null +++ b/circfirm/templates/settings.yaml @@ -0,0 +1,5 @@ +token: + github: "" +output: + supporting: + silence: false diff --git a/circfirm/templates/settings.yaml.license b/circfirm/templates/settings.yaml.license new file mode 100644 index 0000000..0f4980d --- /dev/null +++ b/circfirm/templates/settings.yaml.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries + +SPDX-License-Identifier: MIT diff --git a/pyproject.toml b/pyproject.toml index a626ec4..d9130f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,3 +78,9 @@ source = [ [tool.ruff.lint] select = ["D", "PL", "UP", "I"] ignore = ["D213", "D203"] + +[tool.pytest.ini_options] +filterwarnings = ["ignore:datetime.datetime.utcfromtimestamp():DeprecationWarning"] +addopts = [ + "--ignore=tests/sandbox/circuitpython" +] diff --git a/requirements.txt b/requirements.txt index 5b004c1..e34c218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,11 @@ # # SPDX-License-Identifier: MIT +boto3~=1.34 click~=8.0 +click-spinner~=0.1 packaging~=23.2 psutil~=5.9 pyyaml~=6.0 requests~=2.31 +boto3-stubs[essential]~=1.34 diff --git a/tests/cli/test_cli_cache.py b/tests/cli/test_cli_cache.py index 0d79484..0e030ee 100644 --- a/tests/cli/test_cli_cache.py +++ b/tests/cli/test_cli_cache.py @@ -84,7 +84,7 @@ def test_cache_save() -> None: assert result.exit_code == 1 assert result.output == ( "Caching firmware version 7.3.0 for feather_m4_express... failed\n" - "Error: Could not download spectified UF2 file:\n" + "Error: Could not download the specified UF2 file:\n" "https://downloads.circuitpython.org/bin/feather_m4_express/" "nolanguage/" "adafruit-circuitpython-feather_m4_express-nolanguage-7.3.0.uf2\n" diff --git a/tests/conftest.py b/tests/conftest.py index 275edf4..5796503 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ Author(s): Alec Delaney """ +import os import pathlib import shutil from typing import Union @@ -22,7 +23,20 @@ def pytest_sessionstart(session: pytest.Session) -> None: """Save the current cron table before testing.""" + # Load environment variables if not in GitHub Actions + if "GH_TOKEN" not in os.environ: # pragma: no cover + with open(".env", encoding="utf-8") as envfile: + env_contents = envfile.read() + for envline in env_contents.split("\n"): + if not envline: + continue + name, value = envline.split("=") + os.environ[name] = value + + # Create the backup directory BACKUP_FOLDER.mkdir(exist_ok=True) + + # Save existing settings, if they exist if CONFIG_EXISTS: # pragma: no cover shutil.move(APP_DIR, "tests/backup") diff --git a/tests/helpers.py b/tests/helpers.py index cc0f92d..c7f336f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,6 +11,7 @@ import pathlib import platform import shutil +from typing import List import circfirm import circfirm.backend @@ -79,3 +80,10 @@ def copy_firmwares() -> None: shutil.copytree( board_folder, os.path.join(circfirm.UF2_ARCHIVE, board_folder.name) ) + + +def get_boards_from_git() -> List[str]: + """Get a list of board names from the sandbox git repository.""" + ports_path = pathlib.Path("tests/sandbox/circuitpython") + board_paths = ports_path.glob("ports/*/boards/*") + return sorted([board.name for board in board_paths if board.is_dir()]) diff --git a/tests/test_backend.py b/tests/test_backend.py index 6195605..c1d0968 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -152,3 +152,41 @@ def test_get_firmware_info() -> None: # Test failed parsing with pytest.raises(ValueError): circfirm.backend.get_firmware_info("cannotparse") + + +def test_get_board_list() -> None: + """Tests the ability of the backend to get the board list.""" + # Test successful retrieval + token = os.environ["GH_TOKEN"] + board_list = circfirm.backend.get_board_list(token) + expected_board_list = tests.helpers.get_boards_from_git() + assert board_list == expected_board_list + + # Test unsuccessful retrieval + with pytest.raises(ValueError): + circfirm.backend.get_board_list("badtoken") + + +def test_get_rate_limit() -> None: + """Tests getting the rate limit for an authenticated GitHub request.""" + available, total, reset_time = circfirm.backend.get_rate_limit() + total_rate_limit = 60 + assert available <= total + assert total == total_rate_limit + assert reset_time + + +def test_get_board_versions() -> None: + """Tests getting firmware versions for a given board.""" + board = "adafruit_feather_rp2040" + language = "cs" + expected_versions = [ + "6.2.0-beta.2", + "6.2.0-beta.1", + "6.2.0-beta.0", + ] + versions = circfirm.backend.get_board_versions(board, language) + assert versions == expected_versions + + # Chck that invalid versions are skipped for code coverage + _ = circfirm.backend.get_board_versions(board, regex=r".*") diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..c62e662 --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""Tests the CLI's config command functionality. + +Author(s): Alec Delaney +""" + +import json + +import yaml +from click.testing import CliRunner + +from circfirm.cli import cli + + +def get_printed_default_settings() -> None: + """Get the default (template) settings as printed.""" + with open("circfirm/templates/settings.yaml", encoding="utf-8") as yamlfile: + settings = yaml.safe_load(yamlfile) + return f"{json.dumps(settings, indent=4)}\n" + + +def test_config() -> None: + """Tests the config view command.""" + runner = CliRunner() + expected_output = get_printed_default_settings() + + # Test viewing all settings + result = runner.invoke(cli, ["config", "view"]) + assert result.exit_code == 0 + assert result.output == expected_output + + # Test viewing specific setting + result = runner.invoke(cli, ["config", "view", "output.supporting.silence"]) + assert result.exit_code == 0 + assert result.output == "false\n" + + # Test viewing non-existent setting + result = runner.invoke(cli, ["config", "view", "doesnotexist"]) + assert result.exit_code != 0 + + # Test writing a setting + runner.invoke(cli, ["config", "edit", "output.supporting.silence", "true"]) + result = runner.invoke(cli, ["config", "view", "output.supporting.silence"]) + assert result.exit_code == 0 + assert result.output == "true\n" + runner.invoke(cli, ["config", "edit", "output.supporting.silence", "false"]) + result = runner.invoke(cli, ["config", "view", "output.supporting.silence"]) + assert result.exit_code == 0 + assert result.output == "false\n" + + # Test writing a non-existent setting + result = runner.invoke(cli, ["config", "edit", "doesnotexist", "123"]) + assert result.exit_code != 0 + + # Test writing to setting tree + result = runner.invoke(cli, ["config", "edit", "output", "123"]) + assert result.exit_code != 0 + + # Test writing bad type + runner.invoke(cli, ["config", "edit", "output.supporting.silence", "123"]) + assert result.exit_code != 0 + + +def test_config_reset() -> None: + """Tests the reseting of the config settings file.""" + runner = CliRunner() + + runner.invoke(cli, ["config", "edit", "output.supporting.silence", "true"]) + result = runner.invoke(cli, ["config", "view", "output.supporting.silence"]) + assert result.exit_code == 0 + assert result.output == "true\n" + + expected_settings = get_printed_default_settings() + + result = runner.invoke(cli, ["config", "reset"]) + result = runner.invoke(cli, ["config", "view"]) + assert result.exit_code == 0 + assert result.output == expected_settings diff --git a/tests/test_cli_query.py b/tests/test_cli_query.py new file mode 100644 index 0000000..2fdf78e --- /dev/null +++ b/tests/test_cli_query.py @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: 2024 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""Tests the CLI's query command functionality. + +Author(s): Alec Delaney +""" + +import os +from typing import NoReturn + +import pytest +import requests +from click.testing import CliRunner + +import circfirm.backend +import tests.helpers +from circfirm.cli import cli + + +def simulate_no_connection(arg: str) -> NoReturn: + """Simulate a network error by raising requests.ConnectionError.""" + raise requests.ConnectionError + + +def test_query_boards(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests the ability to query the boards using the CLI.""" + runner = CliRunner() + + # Test an unauthenticated request with supporting text + boards = tests.helpers.get_boards_from_git() + pre_expected_output = "".join([f"{board}\n" for board in boards]) + expected_output = "\n".join( + [ + "Boards list will now be synchronized with the git repository.", + "Please note that this operation can only be performed 60 times per hour due to GitHub rate limiting.", + "Fetching boards list... done", + pre_expected_output, + ] + ) + + result = runner.invoke(cli, ["query", "boards"]) + assert result.exit_code == 0 + assert result.output == expected_output + + # Test an authenticated request without supporting text + result = runner.invoke( + cli, ["config", "edit", "token.github", os.environ["GH_TOKEN"]] + ) + assert result.exit_code == 0 + result = runner.invoke(cli, ["config", "edit", "output.supporting.silence", "true"]) + assert result.exit_code == 0 + result = runner.invoke(cli, ["query", "boards"]) + assert result.exit_code == 0 + assert result.output == pre_expected_output + + # Test a request with a faulty token + result = runner.invoke(cli, ["config", "edit", "token.github", "badtoken"]) + assert result.exit_code == 0 + result = runner.invoke(cli, ["query", "boards"]) + assert result.exit_code != 0 + + result = runner.invoke(cli, ["config", "reset"]) + assert result.exit_code == 0 + + # Tests failure when cannot fetch results due to no network connection + monkeypatch.setattr(circfirm.backend, "get_board_list", simulate_no_connection) + result = runner.invoke(cli, ["query", "boards"]) + assert result.exit_code != 0 + assert ( + result.output.split("\n")[-2] + == "Error: Issue with requesting information from git repository, check network connection" + ) + + +def test_query_versions() -> None: + """Tests the ability to query firmware versions using the CLI.""" + board = "adafruit_feather_rp2040" + language = "cs" + expected_versions = [ + "6.2.0-beta.0", + "6.2.0-beta.1", + "6.2.0-beta.2", + ] + expected_output = "".join([f"{version}\n" for version in expected_versions]) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "versions", + board, + "--language", + language, + ], + ) + assert result.exit_code == 0 + assert result.output == expected_output + + +def test_query_latest() -> None: + """Tests the ability to query the latest version of the firmware using the CLI.""" + board = "adafruit_feather_rp2040" + language = "cs" + expected_output = "6.2.0-beta.2\n" + + # Test without pre-releases included + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "latest", + board, + "--language", + language, + ], + ) + assert result.exit_code == 0 + assert result.output == "" + + # Test with pre-releases included + result = runner.invoke( + cli, + ["query", "latest", board, "--language", language, "--pre-release"], + ) + assert result.exit_code == 0 + assert result.output == expected_output