Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow dashes in board name #35

Merged
merged 2 commits into from
Feb 23, 2024
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
46 changes: 30 additions & 16 deletions circfirm/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import enum
import os
import pathlib
import re
from typing import Dict, List, Optional, Set, Tuple

import packaging.version
Expand Down Expand Up @@ -45,6 +46,18 @@ class Language(enum.Enum):
MANDARIN_LATIN_PINYIN = "zh_Latn_pinyin"


_ALL_LANGAGES = [language.value for language in Language]
_ALL_LANGUAGES_REGEX = "|".join(_ALL_LANGAGES)
FIRMWARE_REGEX = "-".join(
[
r"adafruit-circuitpython-(.*)",
f"({_ALL_LANGUAGES_REGEX})",
r"(\d+\.\d+\.\d+(?:-(?:\balpha\b|\bbeta\b)\.\d+)*)\.uf2",
]
)
BOARD_ID_REGEX = r"Board ID:\s*(.*)"


def _find_device(filename: str) -> Optional[str]:
"""Find a specific connected device."""
for partition in psutil.disk_partitions():
Expand All @@ -69,19 +82,20 @@ def find_bootloader() -> Optional[str]:

def get_board_name(device_path: str) -> str:
"""Get the attached CircuitPython board's name."""
uf2info_file = pathlib.Path(device_path) / circfirm.UF2INFO_FILE
with open(uf2info_file, encoding="utf-8") as infofile:
bootout_file = pathlib.Path(device_path) / circfirm.BOOTOUT_FILE
with open(bootout_file, encoding="utf-8") as infofile:
contents = infofile.read()
model_line = [line.strip() for line in contents.split("\n")][1]
return [comp.strip() for comp in model_line.split(":")][1]
board_match = re.search(BOARD_ID_REGEX, contents)
if not board_match:
raise ValueError("Could not parse the board name from the boot out file")
return board_match[1]


def download_uf2(board: str, version: str, language: str = "en_US") -> None:
"""Download a version of CircuitPython for a specific board."""
file = get_uf2_filename(board, version, language=language)
board_name = board.replace(" ", "_").lower()
uf2_file = get_uf2_filepath(board, version, language=language, ensure=True)
url = f"https://downloads.circuitpython.org/bin/{board_name}/{language}/{file}"
url = f"https://downloads.circuitpython.org/bin/{board}/{language}/{file}"
response = requests.get(url)

SUCCESS = 200
Expand All @@ -105,31 +119,31 @@ def get_uf2_filepath(
) -> pathlib.Path:
"""Get the path to a downloaded UF2 file."""
file = get_uf2_filename(board, version, language)
board_name = board.replace(" ", "_").lower()
uf2_folder = pathlib.Path(circfirm.UF2_ARCHIVE) / board_name
uf2_folder = pathlib.Path(circfirm.UF2_ARCHIVE) / board
if ensure:
circfirm.startup.ensure_dir(uf2_folder)
return pathlib.Path(uf2_folder) / file


def get_uf2_filename(board: str, version: str, language: str = "en_US") -> str:
"""Get the structured name for a specific board/version CircuitPython."""
board_name = board.replace(" ", "_").lower()
return f"adafruit-circuitpython-{board_name}-{language}-{version}.uf2"
return f"adafruit-circuitpython-{board}-{language}-{version}.uf2"


def get_board_folder(board: str) -> pathlib.Path:
"""Get the board folder path."""
board_name = board.replace(" ", "_").lower()
return pathlib.Path(circfirm.UF2_ARCHIVE) / board_name
return pathlib.Path(circfirm.UF2_ARCHIVE) / board


def get_firmware_info(uf2_filename: str) -> Tuple[str, str]:
"""Get firmware info."""
filename_parts = uf2_filename.split("-")
language = filename_parts[3]
version_extension = "-".join(filename_parts[4:])
version = version_extension[:-4]
regex_match = re.match(FIRMWARE_REGEX, uf2_filename)
if regex_match is None:
raise ValueError(
"Firmware information could not be determined from the filename"
)
version = regex_match[3]
language = regex_match[2]
return version, language


Expand Down
30 changes: 20 additions & 10 deletions circfirm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pathlib
import shutil
import sys
import time
from typing import Optional

import click
Expand All @@ -30,8 +31,23 @@ def cli() -> None:
@cli.command()
@click.argument("version")
@click.option("-l", "--language", default="en_US", help="CircuitPython language/locale")
def install(version: str, language: str) -> None:
@click.option("-b", "--board", default=None, help="Assume the given board name")
def install(version: str, language: str, board: Optional[str]) -> None:
"""Install the specified version of CircuitPython."""
if not board:
circuitpy = circfirm.backend.find_circuitpy()
if not circuitpy and circfirm.backend.find_bootloader():
click.echo("CircuitPython device found, but it is in bootloader mode!")
click.echo(
"Please put the device out of bootloader mode, or use the --board option."
)
sys.exit(3)
board = circfirm.backend.get_board_name(circuitpy)

click.echo("Board name detected, please switch the device to bootloader mode.")
while not circfirm.backend.find_bootloader():
time.sleep(1)

mount_path = circfirm.backend.find_bootloader()
if not mount_path:
circuitpy = circfirm.backend.find_circuitpy()
Expand All @@ -44,8 +60,6 @@ def install(version: str, language: str) -> None:
click.echo("Check that the device is connected and mounted.")
sys.exit(1)

board = circfirm.backend.get_board_name(mount_path)

if not circfirm.backend.is_downloaded(board, version, language):
click.echo("Downloading UF2...")
circfirm.backend.download_uf2(board, version, language)
Expand Down Expand Up @@ -76,8 +90,6 @@ def clear(
click.echo("Cache cleared!")
return

board = board.replace(" ", "_").lower()

glob_pattern = "*-*" if board is None else f"*-{board}"
language_pattern = "-*" if language is None else f"-{language}"
glob_pattern += language_pattern
Expand All @@ -99,19 +111,17 @@ def clear(
@click.option("-b", "--board", default=None, help="CircuitPython board name")
def cache_list(board: Optional[str]) -> None:
"""List all the boards/versions cached."""
if board is not None:
board_name = board.replace(" ", "_").lower()
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_name not in board_list:
click.echo(f"No versions for board '{board_name}' are not cached.")
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_name if board is not None else None
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():
Expand Down
6 changes: 3 additions & 3 deletions tests/assets/boot_out.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Adafruit CircuitPython 8.2.9 on 2023-12-06; Adafruit Feather STM32F405 Express with STM32F405RG
Board ID:feather_stm32f405_express
UID:250026001050304235343220
Adafruit CircuitPython 8.0.0-beta.6 on 2022-12-21; Adafruit Feather M4 Express with samd51j19
Board ID:feather_m4_express
UID:C4391B2B0D942955
6 changes: 6 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ def get_mount_node(path: str, must_exist: bool = False) -> str:
return node_location


def delete_mount_node(path: str, missing_okay: bool = False) -> None:
"""Delete a file on the mounted druve."""
node_file = get_mount_node(path)
pathlib.Path(node_file).unlink(missing_ok=missing_okay)


def touch_mount_node(path: str, exist_ok: bool = False) -> str:
"""Touch a file on the mounted drive."""
node_location = get_mount_node(path)
Expand Down
41 changes: 30 additions & 11 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,33 @@ def test_find_bootloader() -> None:

def test_get_board_name() -> None:
"""Tests getting the board name from the UF2 info file."""
# Setup
tests.helpers.delete_mount_node(circfirm.UF2INFO_FILE)
tests.helpers.copy_boot_out()

# Test successful parsing
mount_location = tests.helpers.get_mount()
board_name = circfirm.backend.get_board_name(mount_location)
assert board_name == "PyGamer"
assert board_name == "feather_m4_express"

# Test unsuccessful parsing
with open(
tests.helpers.get_mount_node(circfirm.BOOTOUT_FILE), mode="w", encoding="utf-8"
) as bootfile:
bootfile.write("junktext")
with pytest.raises(ValueError):
circfirm.backend.get_board_name(mount_location)

# Clean up
tests.helpers.delete_mount_node(circfirm.BOOTOUT_FILE)
tests.helpers.copy_uf2_info()


def test_get_board_folder() -> None:
"""Tests getting UF2 information."""
board_name = "Feather M4 Express"
formatted_board_name = board_name.replace(" ", "_").lower()
board_name = "feather_m4_express"
board_path = circfirm.backend.get_board_folder(board_name)
expected_path = pathlib.Path(circfirm.UF2_ARCHIVE) / formatted_board_name
expected_path = pathlib.Path(circfirm.UF2_ARCHIVE) / board_name
assert board_path.resolve() == expected_path.resolve()


Expand All @@ -71,7 +87,7 @@ def test_get_uf2_filepath() -> None:
version = "7.0.0"

created_path = circfirm.backend.get_uf2_filepath(
"Feather M4 Express", "7.0.0", "en_US", ensure=True
"feather_m4_express", "7.0.0", "en_US", ensure=True
)
expected_path = (
pathlib.Path(circfirm.UF2_ARCHIVE)
Expand All @@ -83,16 +99,14 @@ def test_get_uf2_filepath() -> None:

def test_download_uf2() -> None:
"""Tests the UF2 download functionality."""
board_name = "Feather M4 Express"
board_name = "feather_m4_express"
language = "en_US"
version = "junktext"

formatted_board_name = board_name.replace(" ", "_").lower()

# Test bad download candidate
expected_path = (
circfirm.backend.get_board_folder(board_name)
/ f"adafruit-circuitpython-{formatted_board_name}-{language}-{version}.uf2"
/ f"adafruit-circuitpython-{board_name}-{language}-{version}.uf2"
)
with pytest.raises(ConnectionError):
circfirm.backend.download_uf2(board_name, version, language)
Expand All @@ -105,7 +119,7 @@ def test_download_uf2() -> None:
circfirm.backend.download_uf2(board_name, version, language)
expected_path = (
circfirm.backend.get_board_folder(board_name)
/ f"adafruit-circuitpython-{formatted_board_name}-{language}-{version}.uf2"
/ f"adafruit-circuitpython-{board_name}-{language}-{version}.uf2"
)
assert expected_path.exists()
assert circfirm.backend.is_downloaded(board_name, version)
Expand All @@ -116,9 +130,10 @@ def test_download_uf2() -> None:

def test_get_firmware_info() -> None:
"""Tests the ability to get firmware information."""
board_name = "Feather M4 Express"
board_name = "feather_m4_express"
language = "en_US"

# Test successful parsing
for version in ("8.0.0", "9.0.0-beta.2"):
try:
board_folder = circfirm.backend.get_board_folder(board_name)
Expand All @@ -133,3 +148,7 @@ def test_get_firmware_info() -> None:
finally:
# Clean up post tests
shutil.rmtree(board_folder)

# Test failed parsing
with pytest.raises(ValueError):
circfirm.backend.get_firmware_info("cannotparse")
Loading