diff --git a/README.md b/README.md index cc56ef9..79acff0 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,26 @@ After pushing changes, GitHub will automatically build the firmware for you. Run From this page, you can click on a build (the latest is at the top) to view its status. If the build succeeded, you can download the firmware from the "Artifacts" section at the bottom of the build summary page. +## ZMK Version Management + +The `zmk version` command manages the version of ZMK you are using: + +```sh +zmk version # Print the current ZMK version +zmk version --list # List the available versions +zmk version # Switch to the version given by +``` + +You can set the revision to any Git tag, branch, or commit: + +```sh +zmk version v0.3 # Switch to tag "v0.3" +zmk version main # Switch to branch "main" +zmk version 1958217 # Switch to commit "1958217" +``` + +Note that `zmk version --list` will only list tagged versions. + ## Configuration The `zmk config` command manages settings for ZMK CLI: diff --git a/zmk/commands/__init__.py b/zmk/commands/__init__.py index 0227ab1..c4ba620 100644 --- a/zmk/commands/__init__.py +++ b/zmk/commands/__init__.py @@ -4,7 +4,7 @@ import typer -from . import cd, code, config, download, init, keyboard, module, west +from . import cd, code, config, download, init, keyboard, module, version, west def register(app: typer.Typer) -> None: @@ -15,6 +15,7 @@ def register(app: typer.Typer) -> None: app.command()(download.download) app.command(name="dl")(download.download) app.command()(init.init) + app.command(name="version")(version.version) app.command()(west.update) app.command( add_help_option=False, diff --git a/zmk/commands/config.py b/zmk/commands/config.py index 75a3c57..decd0df 100644 --- a/zmk/commands/config.py +++ b/zmk/commands/config.py @@ -47,7 +47,7 @@ def config( ), ] = False, ) -> None: - """Get and set ZMK CLI settings.""" + """Get or set ZMK CLI settings.""" cfg = get_config(ctx) diff --git a/zmk/commands/download.py b/zmk/commands/download.py index 9a03fe8..25db29f 100644 --- a/zmk/commands/download.py +++ b/zmk/commands/download.py @@ -4,12 +4,9 @@ import webbrowser -import giturlparse import typer from ..config import get_config -from ..exceptions import FatalError -from ..repo import Repo def download(ctx: typer.Context) -> None: @@ -18,19 +15,4 @@ def download(ctx: typer.Context) -> None: cfg = get_config(ctx) repo = cfg.get_repo() - actions_url = _get_actions_url(repo) - - webbrowser.open(actions_url) - - -def _get_actions_url(repo: Repo): - remote_url = repo.get_remote_url() - - p = giturlparse.parse(remote_url) - - match p.platform: - case "github": - return f"https://github.com/{p.owner}/{p.repo}/actions/workflows/build.yml" - - case _: - raise FatalError(f"Unsupported remote URL: {remote_url}") + webbrowser.open(repo.get_remote().firmware_download_url) diff --git a/zmk/commands/init.py b/zmk/commands/init.py index 18c8957..7e04c17 100644 --- a/zmk/commands/init.py +++ b/zmk/commands/init.py @@ -7,6 +7,7 @@ import subprocess import webbrowser from pathlib import Path +from typing import Annotated from urllib.parse import urlparse import rich @@ -29,7 +30,24 @@ TEXT_WIDTH = 80 -def init(ctx: typer.Context) -> None: +def init( + ctx: typer.Context, + url: Annotated[ + str | None, typer.Argument(help="URL of an existing repository to clone.") + ] = None, + name: Annotated[ + str | None, + typer.Argument(help="Directory name where the repo should be cloned."), + ] = None, + revision: Annotated[ + str | None, + typer.Option( + "--zmk-version", + metavar="REVISION", + help="Use the specified version of ZMK instead of the default.", + ), + ] = None, +) -> None: """Create a new ZMK config repo or clone an existing one.""" console = rich.get_console() @@ -38,12 +56,28 @@ def init(ctx: typer.Context) -> None: _check_dependencies() _check_for_existing_repo(cfg) - url = _get_repo_url() - name = _get_directory_name(url) + if url is None: + url = _get_repo_url() + + if name is None: + name = _get_directory_name(url) _clone_repo(url, name) repo = Repo(Path() / name) + + if revision: + try: + repo.ensure_west_ready() + repo.set_zmk_version(revision) + except ValueError as ex: + console.print() + console.print( + f'[yellow]Failed to switch to ZMK revision "{revision}":[/yellow]' + ) + console.print(ex) + console.print() + repo.run_west("update") console.print() diff --git a/zmk/commands/module/add.py b/zmk/commands/module/add.py index 4b6f27c..c181edf 100644 --- a/zmk/commands/module/add.py +++ b/zmk/commands/module/add.py @@ -9,7 +9,7 @@ import typer from rich.console import Console from rich.prompt import InvalidResponse, Prompt, PromptBase -from west.manifest import ImportFlag, Manifest +from west.manifest import Manifest from ...config import get_config from ...exceptions import FatalError @@ -37,9 +37,7 @@ def module_add( cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() if name: _error_if_existing_name(manifest, name) diff --git a/zmk/commands/module/list.py b/zmk/commands/module/list.py index 8cbf0f0..ec46e77 100644 --- a/zmk/commands/module/list.py +++ b/zmk/commands/module/list.py @@ -6,7 +6,6 @@ import typer from rich import box from rich.table import Table -from west.manifest import ImportFlag, Manifest from ...config import get_config @@ -19,9 +18,7 @@ def module_list(ctx: typer.Context) -> None: cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() table = Table(box=box.SQUARE, border_style="dim blue", header_style="bright_cyan") table.add_column("Name") diff --git a/zmk/commands/module/remove.py b/zmk/commands/module/remove.py index 16e52e9..d0d4a4d 100644 --- a/zmk/commands/module/remove.py +++ b/zmk/commands/module/remove.py @@ -11,7 +11,7 @@ import rich import typer -from west.manifest import ImportFlag, Manifest, Project +from west.manifest import Project from ...config import get_config from ...exceptions import FatalError @@ -32,9 +32,7 @@ def module_remove( cfg = get_config(ctx) repo = cfg.get_repo() - manifest = Manifest.from_topdir( - topdir=repo.west_path, import_flags=ImportFlag.IGNORE - ) + manifest = repo.get_west_manifest() # Don't allow deleting ZMK, or the repo won't build anymore. projects = [p for p in manifest.projects[1:] if p.name != "zmk"] diff --git a/zmk/commands/version.py b/zmk/commands/version.py new file mode 100644 index 0000000..d1c98f8 --- /dev/null +++ b/zmk/commands/version.py @@ -0,0 +1,76 @@ +""" +"zmk version" command. +""" + +from typing import Annotated + +import rich +import typer +from rich.table import Table + +from ..config import get_config +from ..exceptions import FatalError +from ..remote import Remote +from ..repo import Repo + + +def version( + ctx: typer.Context, + revision: Annotated[ + str | None, + typer.Argument( + help="Switch to this ZMK version. Prints the current ZMK version if omitted.", + ), + ] = None, + list_versions: Annotated[ + bool | None, + typer.Option("--list", "-l", help="Print the available versions and exit."), + ] = False, +) -> None: + """Get or set the ZMK version.""" + + cfg = get_config(ctx) + repo = cfg.get_repo() + + if list_versions: + _print_versions(repo) + elif revision is None: + _print_current_version(repo) + else: + _set_version(repo, revision) + + +def _print_versions(repo: Repo): + zmk = repo.get_west_zmk_project() + remote = Remote(zmk.url) + + if not remote.repo_exists(): + raise FatalError(f"Invalid repository URL: {zmk.url}") + + tags = remote.get_tags() + + if not tags: + raise FatalError(f"{zmk.url} does not have any tagged commits.") + + for tag in tags: + print(tag) + + +def _print_current_version(repo: Repo): + zmk = repo.get_west_zmk_project() + + grid = Table.grid() + grid.add_column() + grid.add_column() + grid.add_row("[bright_blue]Remote: [/bright_blue]", zmk.url) + grid.add_row("[bright_blue]Revision: [/bright_blue]", zmk.revision) + + rich.print(grid) + + +def _set_version(repo: Repo, revision: str): + repo.set_zmk_version(revision) + repo.run_west("update") + + rich.print() + rich.print(f'ZMK is now using revision "{revision}"') diff --git a/zmk/remote.py b/zmk/remote.py new file mode 100644 index 0000000..6eff4c8 --- /dev/null +++ b/zmk/remote.py @@ -0,0 +1,158 @@ +""" +Methods for requesting information from a remote Git repository. +""" + +import math +import re +import subprocess + +import giturlparse + +DEFAULT_TIMEOUT = 10 + + +class Remote: + """Represents a remote Git repository""" + + url: str + + _impl: "_RemoteImpl | None" = None + + def __init__(self, url: str): + self.url = url + + p = giturlparse.parse(url) + + match p.platform: + case "github": + self._impl = _GitHubRemote(p) + + @property + def firmware_download_url(self) -> str: + """URL of a page where users can download firmware builds""" + if self._impl: + return self._impl.firmware_download_url + + raise NotImplementedError(f"Cannot get download URL for {self.url}") + + def repo_exists(self) -> bool: + """Get whether the remote URL points to a valid repo""" + + # Git will return a non-zero status code if it can't access the given URL. + status = subprocess.call( + ["git", "ls-remote", self.url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return status == 0 + + def revision_exists(self, revision: str) -> bool: + """Get whether the remote repo contains a commit with a given revision""" + + # If the given revision is a tag or branch, then ls-remote can find it. + # The output will be empty if the revision isn't found. + if subprocess.check_output(["git", "ls-remote", self.url, revision]): + return True + + # If the given revision is a (possibly abbreviated) commit hash, then + # check if it can be fetched from the remote repo without actually + # fetching it. (This works for commit hashes and tags, but not branches.) + status = subprocess.call( + [ + "git", + "fetch", + self.url, + revision, + "--negotiate-only", + "--negotiation-tip", + revision, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return status == 0 + + def get_tags(self) -> list[str]: + """ + Get a list of tags from the remote repo. + + Tags are sorted in descending order by version. + """ + lines = subprocess.check_output( + ["git", "ls-remote", "--tags", "--refs", self.url], text=True + ).splitlines() + + # ls-remote output is " refs/tags/" for each tag. + # Return only the text after "refs/tags/". + tags = (line.split()[-1].removeprefix("refs/tags/") for line in lines) + + return sorted( + tags, + key=_TaggedVersion, + reverse=True, + ) + + +class _RemoteImpl: + """Implementation for platform-specific accessors""" + + @property + def firmware_download_url(self) -> str: + """URL of a page where users can download firmware builds""" + raise NotImplementedError() + + +class _GitHubRemote(_RemoteImpl): + """Implementation for GitHub""" + + def __init__(self, parsed: giturlparse.GitUrlParsed): + self._parsed = parsed + + @property + def owner(self) -> str: + """Username of the repo's owner""" + return self._parsed.owner + + @property + def repo(self) -> str: + """Name of the repo""" + return self._parsed.repo + + @property + def firmware_download_url(self) -> str: + return ( + f"https://github.com/{self.owner}/{self.repo}/actions/workflows/build.yml" + ) + + +class _TaggedVersion: + major: int | None = None + minor: int | None = None + patch: int | None = None + + def __init__(self, tag: str): + self.tag = tag + + if m := re.match(r"v(\d+)(?:\.(\d+))?(?:\.(\d+))?", self.tag): + self.major = _try_int(m.group(1)) + self.minor = _try_int(m.group(2)) + self.patch = _try_int(m.group(3)) + + def __lt__(self, other: "_TaggedVersion"): + return self._sort_key < other._sort_key + + @property + def _sort_key(self): + return ( + _int_or_inf(self.major), + _int_or_inf(self.minor), + _int_or_inf(self.patch), + ) + + +def _try_int(val: str | None): + return None if val is None else int(val) + + +def _int_or_inf(val: int | None): + return math.inf if val is None else val diff --git a/zmk/repo.py b/zmk/repo.py index f607c30..3aa9a35 100644 --- a/zmk/repo.py +++ b/zmk/repo.py @@ -9,10 +9,14 @@ from pathlib import Path from typing import Any, Literal, overload -from .yaml import read_yaml +import west.manifest + +from .remote import Remote +from .yaml import YAML, read_yaml _APP_DIR_NAME = "app" _BUILD_MATRIX_PATH = "build.yaml" +_BUILD_WORKFLOW_PATH = ".github/workflows/build.yml" _CONFIG_DIR_NAME = "config" _PROJECT_MANIFEST_PATH = f"{_CONFIG_DIR_NAME}/west.yml" _MODULE_MANIFEST_PATH = "zephyr/module.yml" @@ -121,11 +125,38 @@ def get_remote_url(self) -> str: remote = self.git("remote", capture_output=True).strip() return self.git("remote", "get-url", remote, capture_output=True).strip() + def get_remote(self) -> Remote: + """Get a Remote object for the checked out Git branch's remote URL.""" + return Remote(self.get_remote_url()) + + def get_west_manifest(self) -> west.manifest.Manifest: + """Return the parsed contents of the "west.yml" file.""" + return west.manifest.Manifest.from_topdir( + topdir=self.west_path, import_flags=west.manifest.ImportFlag.IGNORE + ) + + def get_west_zmk_project(self) -> west.manifest.Project: + """Return the West project for the "zmk" repo.""" + manifest = self.get_west_manifest() + projects = manifest.get_projects(["zmk"]) + + try: + return projects[0] + except IndexError as ex: + raise RuntimeError( + f'{self.project_manifest_path} is missing "zmk" project.' + ) from ex + @property def build_matrix_path(self) -> Path: """Path to the "build.yaml" file.""" return self.path / _BUILD_MATRIX_PATH + @property + def build_workflow_path(self) -> Path: + """Path to the GitHub workflow build.yml file.""" + return self.path / _BUILD_WORKFLOW_PATH + @property def config_path(self) -> Path: """Path to the "config" folder.""" @@ -201,6 +232,58 @@ def ensure_west_ready(self) -> None: self._west_ready = True + def set_zmk_version(self, revision: str) -> None: + """ + Modifies the "west.yml" file to change the default revision for projects + and modifies the GitHub workflow file to match. + + This does not automatically check out the new revision. Run + Repo.run_west("update") after calling this. + + :raises ValueError: if the given revision does not exist in the remote repo. + """ + zmk = self.get_west_zmk_project() + + remote = Remote(zmk.url) + if not remote.revision_exists(revision): + raise ValueError(f'Revision "{revision}" does not exist in {zmk.url}') + + # Update the project manifest + yaml = YAML() + data = yaml.load(self.project_manifest_path) + + if not "defaults" in data["manifest"]: + data["manifest"]["defaults"] = yaml.map() + + data["manifest"]["defaults"]["revision"] = revision + + for project in data["manifest"]["projects"]: + if project["name"] == "zmk": + try: + del project["revision"] + except KeyError: + pass + break + + yaml.dump(data, self.project_manifest_path) + + # Update the build workflow to match. The user may have customized this + # file, so only update it if it looks like it's using the default workflow, + # and ignore any errors to read or update it. + try: + yaml = YAML() + data = yaml.load(self.build_workflow_path) + + build = data["jobs"]["build"] + workflow, _, _ = build["uses"].rpartition("@") + + if workflow.endswith(".github/workflows/build-user-config.yml"): + build["uses"] = f"{workflow}@{revision}" + + yaml.dump(data, self.build_workflow_path) + except KeyError: + pass + @overload def _run_west(self, *args: str, capture_output: Literal[False] = False) -> None: ... diff --git a/zmk/yaml.py b/zmk/yaml.py index 35bf618..1a976e6 100644 --- a/zmk/yaml.py +++ b/zmk/yaml.py @@ -18,6 +18,22 @@ class YAML(ruamel.yaml.YAML): readable or seekable, a leading comment will be overwritten. """ + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.preserve_quotes = True + self.indent(mapping=2, sequence=4, offset=2) + + def load(self, stream: Path | IO | str) -> Any: + # Reading from a Path causes a round-trip conversion to drop blank lines + # for some reason. As a workaround, just read in the whole file to a + # string and parse that. It's probably slower, but none of the file we + # work with are large enough for it to matter. + if isinstance(stream, Path): + return super().load(stream.read_text()) + + return super().load(stream) + def dump(self, data, stream: Path | IO | None = None, *, transform=None) -> None: if stream is None: raise TypeError("Dumping from a context manager is not supported.")