Skip to content

Commit 5326dbb

Browse files
committed
feat: Add "zmk version" command
Added a new "zmk version" command which can print the current ZMK version, list available tagged versions, and switch to a new version. Also added a new Remote class, which queries information about remote Git repositories, and some new utility methods on Repo to get remote and West manifest information. Updated some existing commands to use these. Partially implements zmkfirmware#52
1 parent d9a27fa commit 5326dbb

File tree

10 files changed

+314
-34
lines changed

10 files changed

+314
-34
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,26 @@ After pushing changes, GitHub will automatically build the firmware for you. Run
209209

210210
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.
211211

212+
## ZMK Version Management
213+
214+
The `zmk version` command manages the version of ZMK you are using:
215+
216+
```sh
217+
zmk version # Print the current ZMK version
218+
zmk version --list # List the available versions
219+
zmk version <revision> # Switch to the version given by <revision>
220+
```
221+
222+
You can set the revision to any Git tag, branch, or commit:
223+
224+
```sh
225+
zmk version v0.3 # Switch to tag "v0.3"
226+
zmk version main # Switch to branch "main"
227+
zmk version 1958217 # Switch to commit "1958217"
228+
```
229+
230+
Note that `zmk version --list` will only list tagged versions.
231+
212232
## Configuration
213233

214234
The `zmk config` command manages settings for ZMK CLI:

zmk/commands/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import typer
66

7-
from . import cd, code, config, download, init, keyboard, module, west
7+
from . import cd, code, config, download, init, keyboard, module, version, west
88

99

1010
def register(app: typer.Typer) -> None:
@@ -15,6 +15,7 @@ def register(app: typer.Typer) -> None:
1515
app.command()(download.download)
1616
app.command(name="dl")(download.download)
1717
app.command()(init.init)
18+
app.command(name="version")(version.version)
1819
app.command()(west.update)
1920
app.command(
2021
add_help_option=False,

zmk/commands/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def config(
4747
),
4848
] = False,
4949
) -> None:
50-
"""Get and set ZMK CLI settings."""
50+
"""Get or set ZMK CLI settings."""
5151

5252
cfg = get_config(ctx)
5353

zmk/commands/download.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@
44

55
import webbrowser
66

7-
import giturlparse
87
import typer
98

109
from ..config import get_config
11-
from ..exceptions import FatalError
12-
from ..repo import Repo
1310

1411

1512
def download(ctx: typer.Context) -> None:
@@ -18,19 +15,4 @@ def download(ctx: typer.Context) -> None:
1815
cfg = get_config(ctx)
1916
repo = cfg.get_repo()
2017

21-
actions_url = _get_actions_url(repo)
22-
23-
webbrowser.open(actions_url)
24-
25-
26-
def _get_actions_url(repo: Repo):
27-
remote_url = repo.get_remote_url()
28-
29-
p = giturlparse.parse(remote_url)
30-
31-
match p.platform:
32-
case "github":
33-
return f"https://github.com/{p.owner}/{p.repo}/actions/workflows/build.yml"
34-
35-
case _:
36-
raise FatalError(f"Unsupported remote URL: {remote_url}")
18+
webbrowser.open(repo.get_remote().firmware_download_url)

zmk/commands/module/add.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import typer
1010
from rich.console import Console
1111
from rich.prompt import InvalidResponse, Prompt, PromptBase
12-
from west.manifest import ImportFlag, Manifest
12+
from west.manifest import Manifest
1313

1414
from ...config import get_config
1515
from ...exceptions import FatalError
@@ -37,9 +37,7 @@ def module_add(
3737
cfg = get_config(ctx)
3838
repo = cfg.get_repo()
3939

40-
manifest = Manifest.from_topdir(
41-
topdir=repo.west_path, import_flags=ImportFlag.IGNORE
42-
)
40+
manifest = repo.get_west_manifest()
4341

4442
if name:
4543
_error_if_existing_name(manifest, name)

zmk/commands/module/list.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import typer
77
from rich import box
88
from rich.table import Table
9-
from west.manifest import ImportFlag, Manifest
109

1110
from ...config import get_config
1211

@@ -19,9 +18,7 @@ def module_list(ctx: typer.Context) -> None:
1918
cfg = get_config(ctx)
2019
repo = cfg.get_repo()
2120

22-
manifest = Manifest.from_topdir(
23-
topdir=repo.west_path, import_flags=ImportFlag.IGNORE
24-
)
21+
manifest = repo.get_west_manifest()
2522

2623
table = Table(box=box.SQUARE, border_style="dim blue", header_style="bright_cyan")
2724
table.add_column("Name")

zmk/commands/module/remove.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import rich
1313
import typer
14-
from west.manifest import ImportFlag, Manifest, Project
14+
from west.manifest import Project
1515

1616
from ...config import get_config
1717
from ...exceptions import FatalError
@@ -32,9 +32,7 @@ def module_remove(
3232
cfg = get_config(ctx)
3333
repo = cfg.get_repo()
3434

35-
manifest = Manifest.from_topdir(
36-
topdir=repo.west_path, import_flags=ImportFlag.IGNORE
37-
)
35+
manifest = repo.get_west_manifest()
3836

3937
# Don't allow deleting ZMK, or the repo won't build anymore.
4038
projects = [p for p in manifest.projects[1:] if p.name != "zmk"]

zmk/commands/version.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""
2+
"zmk version" command.
3+
"""
4+
5+
from typing import Annotated
6+
7+
import rich
8+
import typer
9+
from rich.table import Table
10+
11+
from ..config import get_config
12+
from ..exceptions import FatalError
13+
from ..remote import Remote
14+
from ..repo import Repo
15+
16+
17+
def version(
18+
ctx: typer.Context,
19+
revision: Annotated[
20+
str | None,
21+
typer.Argument(
22+
help="Switch to this ZMK version. Prints the current ZMK version if omitted.",
23+
),
24+
] = None,
25+
list_versions: Annotated[
26+
bool | None,
27+
typer.Option("--list", "-l", help="Print the available versions and exit."),
28+
] = False,
29+
) -> None:
30+
"""Get or set the ZMK version."""
31+
32+
cfg = get_config(ctx)
33+
repo = cfg.get_repo()
34+
35+
if list_versions:
36+
_print_versions(repo)
37+
elif revision is None:
38+
_print_current_version(repo)
39+
else:
40+
_set_version(repo, revision)
41+
42+
43+
def _print_versions(repo: Repo):
44+
zmk = repo.get_west_zmk_project()
45+
remote = Remote(zmk.url)
46+
47+
if not remote.repo_exists():
48+
raise FatalError(f"Invalid repository URL: {zmk.url}")
49+
50+
tags = remote.get_tags()
51+
52+
if not tags:
53+
raise FatalError(f"{zmk.url} does not have any tagged commits.")
54+
55+
for tag in tags:
56+
print(tag)
57+
58+
59+
def _print_current_version(repo: Repo):
60+
zmk = repo.get_west_zmk_project()
61+
62+
grid = Table.grid()
63+
grid.add_column()
64+
grid.add_column()
65+
grid.add_row("[bright_blue]Remote: [/bright_blue]", zmk.url)
66+
grid.add_row("[bright_blue]Revision: [/bright_blue]", zmk.revision)
67+
68+
rich.print(grid)
69+
70+
71+
def _set_version(repo: Repo, revision: str):
72+
repo.set_zmk_version(revision)
73+
repo.run_west("update", "zmk")
74+
75+
rich.print()
76+
rich.print(f'ZMK is now using revision "{revision}"')

zmk/remote.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Methods for requesting information from a remote Git repository.
3+
"""
4+
5+
import math
6+
import re
7+
import subprocess
8+
9+
import giturlparse
10+
11+
DEFAULT_TIMEOUT = 10
12+
13+
14+
class Remote:
15+
"""Represents a remote Git repository"""
16+
17+
url: str
18+
19+
_impl: "_RemoteImpl | None" = None
20+
21+
def __init__(self, url: str):
22+
self.url = url
23+
24+
p = giturlparse.parse(url)
25+
26+
match p.platform:
27+
case "github":
28+
self._impl = _GitHubRemote(p)
29+
30+
@property
31+
def firmware_download_url(self) -> str:
32+
"""URL of a page where users can download firmware builds"""
33+
if self._impl:
34+
return self._impl.firmware_download_url
35+
36+
raise NotImplementedError(f"Cannot get download URL for {self.url}")
37+
38+
def repo_exists(self) -> bool:
39+
"""Get whether the remote URL points to a valid repo"""
40+
41+
# Git will return a non-zero status code if it can't access the given URL.
42+
status = subprocess.call(
43+
["git", "ls-remote", self.url],
44+
stdout=subprocess.DEVNULL,
45+
stderr=subprocess.DEVNULL,
46+
)
47+
return status == 0
48+
49+
def revision_exists(self, revision: str) -> bool:
50+
"""Get whether the remote repo contains a commit with a given revision"""
51+
52+
# If the given revision is a tag or branch, then ls-remote can find it.
53+
# The output will be empty if the revision isn't found.
54+
if subprocess.check_output(["git", "ls-remote", self.url, revision]):
55+
return True
56+
57+
# If the given revision is a (possibly abbreviated) commit hash, then
58+
# check if it can be fetched from the remote repo without actually
59+
# fetching it. (This works for commit hashes and tags, but not branches.)
60+
status = subprocess.call(
61+
[
62+
"git",
63+
"fetch",
64+
self.url,
65+
revision,
66+
"--negotiate-only",
67+
"--negotiation-tip",
68+
revision,
69+
],
70+
stdout=subprocess.DEVNULL,
71+
stderr=subprocess.DEVNULL,
72+
)
73+
return status == 0
74+
75+
def get_tags(self) -> list[str]:
76+
"""
77+
Get a list of tags from the remote repo.
78+
79+
Tags are sorted in descending order by version.
80+
"""
81+
lines = subprocess.check_output(
82+
["git", "ls-remote", "--tags", "--refs", self.url], text=True
83+
).splitlines()
84+
85+
# ls-remote output is "<hash> refs/tags/<tag>" for each tag.
86+
# Return only the text after "refs/tags/".
87+
tags = (line.split()[-1].removeprefix("refs/tags/") for line in lines)
88+
89+
return sorted(
90+
tags,
91+
key=_TaggedVersion,
92+
reverse=True,
93+
)
94+
95+
96+
class _RemoteImpl:
97+
"""Implementation for platform-specific accessors"""
98+
99+
@property
100+
def firmware_download_url(self) -> str:
101+
"""URL of a page where users can download firmware builds"""
102+
raise NotImplementedError()
103+
104+
105+
class _GitHubRemote(_RemoteImpl):
106+
"""Implementation for GitHub"""
107+
108+
def __init__(self, parsed: giturlparse.GitUrlParsed):
109+
self._parsed = parsed
110+
111+
@property
112+
def owner(self) -> str:
113+
"""Username of the repo's owner"""
114+
return self._parsed.owner
115+
116+
@property
117+
def repo(self) -> str:
118+
"""Name of the repo"""
119+
return self._parsed.repo
120+
121+
@property
122+
def firmware_download_url(self) -> str:
123+
return (
124+
f"https://github.com/{self.owner}/{self.repo}/actions/workflows/build.yml"
125+
)
126+
127+
128+
class _TaggedVersion:
129+
major: int | None = None
130+
minor: int | None = None
131+
patch: int | None = None
132+
133+
def __init__(self, tag: str):
134+
self.tag = tag
135+
136+
if m := re.match(r"v(\d+)(?:\.(\d+))?(?:\.(\d+))?", self.tag):
137+
self.major = _try_int(m.group(1))
138+
self.minor = _try_int(m.group(2))
139+
self.patch = _try_int(m.group(3))
140+
141+
def __lt__(self, other: _TaggedVersion):
142+
return self._sort_key < other._sort_key
143+
144+
@property
145+
def _sort_key(self):
146+
return (
147+
_int_or_inf(self.major),
148+
_int_or_inf(self.minor),
149+
_int_or_inf(self.patch),
150+
)
151+
152+
153+
def _try_int(val: str | None):
154+
return None if val is None else int(val)
155+
156+
157+
def _int_or_inf(val: int | None):
158+
return math.inf if val is None else val

0 commit comments

Comments
 (0)