oaknut-cli 12.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Smallshire
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: oaknut-cli
3
+ Version: 12.0.0
4
+ Summary: Shared CLI toolkit for the oaknut family: the contributed-command axis and report-rendering helpers a disc command needs, below the filesystem packages.
5
+ Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-cli
8
+ Project-URL: Repository, https://github.com/rob-smallshire/oaknut
9
+ Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Filesystems
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: oaknut-exception>=10.0
23
+ Requires-Dist: oaknut-extension>=10.0
24
+ Requires-Dist: oaknut-file>=10.0
25
+ Requires-Dist: click>=8.1.7
26
+ Requires-Dist: asyoulikeit>=1.2.0
27
+ Dynamic: license-file
28
+
29
+ # oaknut-cli
30
+
31
+ Shared CLI toolkit for the [oaknut](https://github.com/rob-smallshire/oaknut)
32
+ family. It sits *below* the filesystem packages so that both the `disc`
33
+ CLI (`oaknut-disc`) and a filesystem's own contributed commands can
34
+ depend on it without a dependency cycle.
35
+
36
+ It provides the **contributed-command axis** — discovery of Click
37
+ commands a filesystem package registers on the `oaknut.command`
38
+ entry-point namespace — and report-rendering helpers shared between the
39
+ generic `disc` commands and the contributed ones.
40
+
41
+ See `docs/dev/contributed-commands.md` for the design.
@@ -0,0 +1,13 @@
1
+ # oaknut-cli
2
+
3
+ Shared CLI toolkit for the [oaknut](https://github.com/rob-smallshire/oaknut)
4
+ family. It sits *below* the filesystem packages so that both the `disc`
5
+ CLI (`oaknut-disc`) and a filesystem's own contributed commands can
6
+ depend on it without a dependency cycle.
7
+
8
+ It provides the **contributed-command axis** — discovery of Click
9
+ commands a filesystem package registers on the `oaknut.command`
10
+ entry-point namespace — and report-rendering helpers shared between the
11
+ generic `disc` commands and the contributed ones.
12
+
13
+ See `docs/dev/contributed-commands.md` for the design.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oaknut-cli"
7
+ dynamic = ["version"]
8
+ authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
9
+ description = "Shared CLI toolkit for the oaknut family: the contributed-command axis and report-rendering helpers a disc command needs, below the filesystem packages."
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.11"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: System :: Filesystems",
24
+ ]
25
+ dependencies = [
26
+ "oaknut-exception>=10.0",
27
+ "oaknut-extension>=10.0",
28
+ "oaknut-file>=10.0",
29
+ "click>=8.1.7",
30
+ "asyoulikeit>=1.2.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-cli"
35
+ Repository = "https://github.com/rob-smallshire/oaknut"
36
+ Issues = "https://github.com/rob-smallshire/oaknut/issues"
37
+
38
+ [dependency-groups]
39
+ test = [
40
+ "pytest>=8.0",
41
+ ]
42
+ dev = [
43
+ "bump-my-version>=0.28.0",
44
+ "pre-commit>=3.0",
45
+ {include-group = "test"},
46
+ ]
47
+
48
+ [tool.setuptools.dynamic]
49
+ version = { attr = "oaknut.cli.__version__" }
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,51 @@
1
+ """Shared CLI toolkit for the oaknut family.
2
+
3
+ This package sits *below* the filesystem packages (oaknut-dfs/adfs/afs)
4
+ so that both the ``disc`` CLI (``oaknut-disc``) and a filesystem's own
5
+ contributed commands can depend on it without a cycle — ``oaknut-disc``
6
+ depends on the filesystem packages, so they must never depend on it.
7
+
8
+ It provides:
9
+
10
+ - the **contributed-command axis** — discovery of Click commands
11
+ registered by filesystem packages on the ``oaknut.command`` entry-point
12
+ namespace (see :func:`contributed_commands`); and
13
+ - report-rendering helpers shared between the generic ``disc`` commands
14
+ and the contributed ones.
15
+
16
+ See ``docs/dev/contributed-commands.md`` for the design.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from oaknut.cli.commands import (
22
+ COMMAND_KIND,
23
+ COMMAND_NAMESPACE,
24
+ contributed_commands,
25
+ )
26
+ from oaknut.cli.help import (
27
+ PlainHelpFormatter,
28
+ strip_rst,
29
+ use_plain_help,
30
+ )
31
+ from oaknut.cli.reports import (
32
+ SECTOR_SIZE,
33
+ address_cell,
34
+ kv_table,
35
+ size_cell,
36
+ )
37
+
38
+ __version__ = "12.0.0"
39
+
40
+ __all__ = [
41
+ "COMMAND_KIND",
42
+ "COMMAND_NAMESPACE",
43
+ "contributed_commands",
44
+ "PlainHelpFormatter",
45
+ "strip_rst",
46
+ "use_plain_help",
47
+ "SECTOR_SIZE",
48
+ "address_cell",
49
+ "kv_table",
50
+ "size_cell",
51
+ ]
@@ -0,0 +1,55 @@
1
+ """The contributed-command axis.
2
+
3
+ A filesystem package contributes admin subcommands to the ``disc`` CLI by
4
+ registering a Click command (or group) on the ``oaknut.command``
5
+ entry-point namespace::
6
+
7
+ [project.entry-points."oaknut.command"]
8
+ afs = "oaknut.afs.cli:afs" # afs is a click.Group
9
+
10
+ :func:`contributed_commands` discovers them; the ``disc`` root group
11
+ attaches each with ``cli.add_command(...)``. An install sees exactly the
12
+ commands its installed filesystems contribute.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ import stevedore
20
+ from oaknut.extension import namespace_for
21
+
22
+ if TYPE_CHECKING:
23
+ import click
24
+
25
+ #: The extension *kind* (axis) contributed CLI commands belong to.
26
+ COMMAND_KIND = "command"
27
+
28
+ #: The entry-point namespace commands register under: ``"oaknut.command"``.
29
+ COMMAND_NAMESPACE = namespace_for(COMMAND_KIND)
30
+
31
+
32
+ def _skip_unloadable(manager, entrypoint, exception) -> None:
33
+ """Ignore a command whose package cannot be imported.
34
+
35
+ A filesystem installed without its optional ``[cli]`` extra (so Click
36
+ is absent) leaves its command entry point unloadable. That command is
37
+ simply unavailable — not a fatal error — so the CLI degrades
38
+ gracefully rather than crashing.
39
+ """
40
+
41
+
42
+ def contributed_commands() -> list["click.Command"]:
43
+ """Every Click command contributed on the ``oaknut.command`` axis.
44
+
45
+ Each entry point's target is a ready-made Click ``Command`` or
46
+ ``Group`` object. Returned sorted by name so the CLI's command
47
+ listing is deterministic regardless of discovery order.
48
+ """
49
+ manager = stevedore.ExtensionManager(
50
+ namespace=COMMAND_NAMESPACE,
51
+ invoke_on_load=False,
52
+ on_load_failure_callback=_skip_unloadable,
53
+ )
54
+ commands = [extension.plugin for extension in manager]
55
+ return sorted(commands, key=lambda command: getattr(command, "name", "") or "")
@@ -0,0 +1,71 @@
1
+ """Terminal-friendly help: strip RST inline markup as Click renders it.
2
+
3
+ Command docstrings and option help are dual-purpose — Click prints them
4
+ verbatim at the terminal, while the ``disc`` Sphinx command reference
5
+ renders them as reStructuredText. RST inline markup (``literal`` spans)
6
+ that reads as code in the manual shows as stray backticks in a terminal.
7
+
8
+ :func:`use_plain_help` makes a command (and, for a group, its whole
9
+ subtree) render help through a formatter that reduces that markup to
10
+ plain text at display time, leaving the docstrings untouched for Sphinx.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections.abc import Iterable
17
+
18
+ import click
19
+
20
+ __all__ = ["strip_rst", "PlainHelpFormatter", "use_plain_help"]
21
+
22
+ # ``literal`` and `interpreted` spans — one or two backticks either side.
23
+ _BACKTICK_SPAN = re.compile(r"`{1,2}([^`]+)`{1,2}")
24
+
25
+
26
+ def strip_rst(text: str) -> str:
27
+ """Reduce the RST inline markup used in help text to plain text.
28
+
29
+ ``code`` and `code` both collapse to ``code`` — readable at a
30
+ terminal. Only backtick spans are touched; everything else is left
31
+ as written.
32
+ """
33
+ return _BACKTICK_SPAN.sub(r"\1", text)
34
+
35
+
36
+ class PlainHelpFormatter(click.HelpFormatter):
37
+ """A Click help formatter that strips RST markup as it writes.
38
+
39
+ Every piece of help text — the body paragraphs (:meth:`write_text`)
40
+ and the option / subcommand definition lists (:meth:`write_dl`) —
41
+ passes through :func:`strip_rst` on the way out.
42
+ """
43
+
44
+ def write_text(self, text: str) -> None:
45
+ super().write_text(strip_rst(text))
46
+
47
+ def write_dl(self, rows: Iterable[tuple[str, str]], *args: object, **kwargs: object) -> None:
48
+ super().write_dl(
49
+ [(strip_rst(term), strip_rst(definition)) for term, definition in rows],
50
+ *args,
51
+ **kwargs,
52
+ )
53
+
54
+
55
+ class _PlainHelpContext(click.Context):
56
+ """A Click context whose help is formatted by :class:`PlainHelpFormatter`."""
57
+
58
+ formatter_class = PlainHelpFormatter
59
+
60
+
61
+ def use_plain_help(command: click.Command) -> None:
62
+ """Render *command*'s help with RST markup stripped.
63
+
64
+ For a group, applies to the whole subtree, so a single call on a
65
+ fully-assembled CLI covers every subcommand — including ones
66
+ contributed by other packages, regardless of how they were built.
67
+ """
68
+ command.context_class = _PlainHelpContext
69
+ if isinstance(command, click.Group):
70
+ for sub in command.commands.values():
71
+ use_plain_help(sub)
@@ -0,0 +1,58 @@
1
+ """Report-rendering helpers shared across ``disc`` commands.
2
+
3
+ Audience-aware cells (a friendly string for humans, a raw value for
4
+ machine formatters) and a transposed key-value table, used by both the
5
+ generic ``disc`` commands and the filesystem-contributed ones. Built on
6
+ asyoulikeit; kept here, below the filesystem packages, so a contributed
7
+ command can render output without depending on ``oaknut-disc``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from asyoulikeit import ByAudience
13
+ from oaknut.file.capacity import format_capacity
14
+
15
+ #: Acorn discs are addressed in 256-byte sectors throughout.
16
+ SECTOR_SIZE = 256
17
+
18
+
19
+ def size_cell(sectors: int) -> ByAudience:
20
+ """A capacity (given in sectors) as an audience-aware cell.
21
+
22
+ Humans read friendly IEC units (``800.0 KiB``); machine formatters
23
+ get the raw byte count as an integer, so the presentation base is
24
+ irrelevant to a consumer.
25
+ """
26
+ num_bytes = sectors * SECTOR_SIZE
27
+ return ByAudience(machine=num_bytes, human=format_capacity(num_bytes))
28
+
29
+
30
+ def address_cell(address: int) -> ByAudience:
31
+ """A 32-bit Acorn address as an audience-aware cell.
32
+
33
+ Humans read the conventional ``0x``-prefixed 8-hex-digit form;
34
+ machine formatters (JSON, TSV) get the raw integer, so a consumer
35
+ never has to parse a base back out of a string.
36
+ """
37
+ return ByAudience(machine=address, human=f"0x{address:08X}")
38
+
39
+
40
+ def kv_table(title: str, pairs: list[tuple[str, str, object]]):
41
+ """Build a transposed single-row table from (key, label, value) tuples.
42
+
43
+ Each tuple becomes a column whose sole row holds the value. A value
44
+ may be a plain string, an integer (for machine-readable counts), or
45
+ a :class:`~asyoulikeit.ByAudience` cell that renders one way for
46
+ humans and another for machine formatters. Transposed presentation
47
+ turns the one-row table into a key-value report in the display
48
+ formatter.
49
+ """
50
+ from asyoulikeit.tabular_data import TableContent
51
+
52
+ tc = TableContent(title=title, present_transposed=True)
53
+ row: dict = {}
54
+ for index, (key, label, value) in enumerate(pairs):
55
+ tc.add_column(key, label, header=(index == 0))
56
+ row[key] = value
57
+ tc.add_row(**row)
58
+ return tc
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: oaknut-cli
3
+ Version: 12.0.0
4
+ Summary: Shared CLI toolkit for the oaknut family: the contributed-command axis and report-rendering helpers a disc command needs, below the filesystem packages.
5
+ Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-cli
8
+ Project-URL: Repository, https://github.com/rob-smallshire/oaknut
9
+ Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Filesystems
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: oaknut-exception>=10.0
23
+ Requires-Dist: oaknut-extension>=10.0
24
+ Requires-Dist: oaknut-file>=10.0
25
+ Requires-Dist: click>=8.1.7
26
+ Requires-Dist: asyoulikeit>=1.2.0
27
+ Dynamic: license-file
28
+
29
+ # oaknut-cli
30
+
31
+ Shared CLI toolkit for the [oaknut](https://github.com/rob-smallshire/oaknut)
32
+ family. It sits *below* the filesystem packages so that both the `disc`
33
+ CLI (`oaknut-disc`) and a filesystem's own contributed commands can
34
+ depend on it without a dependency cycle.
35
+
36
+ It provides the **contributed-command axis** — discovery of Click
37
+ commands a filesystem package registers on the `oaknut.command`
38
+ entry-point namespace — and report-rendering helpers shared between the
39
+ generic `disc` commands and the contributed ones.
40
+
41
+ See `docs/dev/contributed-commands.md` for the design.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/oaknut/cli/__init__.py
5
+ src/oaknut/cli/commands.py
6
+ src/oaknut/cli/help.py
7
+ src/oaknut/cli/reports.py
8
+ src/oaknut_cli.egg-info/PKG-INFO
9
+ src/oaknut_cli.egg-info/SOURCES.txt
10
+ src/oaknut_cli.egg-info/dependency_links.txt
11
+ src/oaknut_cli.egg-info/requires.txt
12
+ src/oaknut_cli.egg-info/top_level.txt
13
+ tests/test_commands.py
14
+ tests/test_help.py
@@ -0,0 +1,5 @@
1
+ oaknut-exception>=10.0
2
+ oaknut-extension>=10.0
3
+ oaknut-file>=10.0
4
+ click>=8.1.7
5
+ asyoulikeit>=1.2.0
@@ -0,0 +1,47 @@
1
+ """Tests for the contributed-command axis."""
2
+
3
+ from oaknut.cli import COMMAND_NAMESPACE, contributed_commands
4
+
5
+
6
+ def test_command_namespace():
7
+ assert COMMAND_NAMESPACE == "oaknut.command"
8
+
9
+
10
+ def test_contributed_commands_is_a_sorted_list():
11
+ # Whatever command-contributing packages are installed, discovery
12
+ # returns a list and does not raise. (Empty until a filesystem
13
+ # registers an oaknut.command entry point.)
14
+ commands = contributed_commands()
15
+ assert isinstance(commands, list)
16
+ names = [getattr(c, "name", "") for c in commands]
17
+ assert names == sorted(names)
18
+
19
+
20
+ class TestSkipsUnloadable:
21
+ def test_a_failing_entry_point_does_not_crash_discovery(self, monkeypatch):
22
+ # Simulate one entry point importing nothing usable (e.g. a
23
+ # filesystem installed without its [cli] extra): discovery must
24
+ # skip it, not raise.
25
+ import stevedore
26
+ from oaknut.cli import commands as commands_module
27
+
28
+ class _FakeExtension:
29
+ name = "ok"
30
+ plugin = type("Cmd", (), {"name": "ok"})()
31
+
32
+ class _FakeManager:
33
+ def __init__(self, *args, on_load_failure_callback=None, **kwargs):
34
+ # Exercise the skip callback with a synthetic failure, then
35
+ # yield one good extension.
36
+ if on_load_failure_callback is not None:
37
+ on_load_failure_callback(self, _FakeEntryPoint(), ImportError("no click"))
38
+
39
+ def __iter__(self):
40
+ return iter([_FakeExtension()])
41
+
42
+ class _FakeEntryPoint:
43
+ name = "broken"
44
+
45
+ monkeypatch.setattr(stevedore, "ExtensionManager", _FakeManager)
46
+ result = commands_module.contributed_commands()
47
+ assert [c.name for c in result] == ["ok"]
@@ -0,0 +1,47 @@
1
+ """Tests for the terminal-friendly help machinery."""
2
+
3
+ import click
4
+ from click.testing import CliRunner
5
+ from oaknut.cli import PlainHelpFormatter, strip_rst, use_plain_help
6
+
7
+
8
+ class TestStripRst:
9
+ def test_double_backticks_become_plain(self):
10
+ assert strip_rst("pass ``--geometry`` to set it") == "pass --geometry to set it"
11
+
12
+ def test_single_backticks_become_plain(self):
13
+ assert strip_rst("run `disc list-filesystems`") == "run disc list-filesystems"
14
+
15
+ def test_text_without_markup_is_unchanged(self):
16
+ assert strip_rst("plain text, nothing to do") == "plain text, nothing to do"
17
+
18
+
19
+ class TestUsePlainHelp:
20
+ def _cli(self):
21
+ @click.group()
22
+ def root():
23
+ """Root group with ``markup`` in its help."""
24
+
25
+ @root.command()
26
+ @click.option("--thing", help="A thing; see `other-command`.")
27
+ def sub(thing):
28
+ """Do a ``thing`` to the disc."""
29
+
30
+ return root
31
+
32
+ def test_help_text_is_stripped(self):
33
+ root = self._cli()
34
+ use_plain_help(root)
35
+ result = CliRunner().invoke(root, ["sub", "--help"])
36
+ assert result.exit_code == 0
37
+ assert "`" not in result.output
38
+ # Content survives, only the markup is gone.
39
+ assert "Do a thing to the disc." in result.output
40
+ assert "see other-command." in result.output
41
+
42
+ def test_applies_to_the_whole_subtree(self):
43
+ root = self._cli()
44
+ use_plain_help(root)
45
+ # Both the group's own help and the subcommand's use the formatter.
46
+ assert root.context_class.formatter_class is PlainHelpFormatter
47
+ assert root.commands["sub"].context_class.formatter_class is PlainHelpFormatter