help2skill 0.1.0__py3-none-any.whl
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.
- help2skill/__init__.py +243 -0
- help2skill/__main__.py +3 -0
- help2skill/_argparse.py +173 -0
- help2skill/_click.py +256 -0
- help2skill/cli.py +264 -0
- help2skill/click.py +98 -0
- help2skill/model.py +89 -0
- help2skill/privacy.py +33 -0
- help2skill/py.typed +0 -0
- help2skill/render.py +286 -0
- help2skill/sync.py +448 -0
- help2skill/testing.py +37 -0
- help2skill/typer.py +42 -0
- help2skill-0.1.0.dist-info/METADATA +206 -0
- help2skill-0.1.0.dist-info/RECORD +18 -0
- help2skill-0.1.0.dist-info/WHEEL +4 -0
- help2skill-0.1.0.dist-info/entry_points.txt +2 -0
- help2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
help2skill/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""The ``help2skill`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import importlib
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import (
|
|
14
|
+
REFERENCE_FILE,
|
|
15
|
+
SKILL_MAX_CHARS,
|
|
16
|
+
UNNAMED,
|
|
17
|
+
__version__,
|
|
18
|
+
check_skill_file,
|
|
19
|
+
find_machine_paths,
|
|
20
|
+
introspect,
|
|
21
|
+
program_name,
|
|
22
|
+
read_settings,
|
|
23
|
+
redact_home,
|
|
24
|
+
render_llms,
|
|
25
|
+
write_skill,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_STALE_KINDS = ("stale-block", "missing-block", "stale-reference", "missing-reference")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UsageError(Exception):
|
|
32
|
+
"""A problem with the user's input: printed as one line, exit status 2."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="help2skill",
|
|
38
|
+
description="Generate LLM-ready docs and SKILL.md files from Click, Typer and argparse CLIs.",
|
|
39
|
+
epilog="TARGET is the import path of the CLI object, e.g. mypkg.cli:app. For a function "
|
|
40
|
+
"that builds an argparse parser, write mypkg.cli:build_parser() (with parentheses).",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
43
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
44
|
+
|
|
45
|
+
md = sub.add_parser("markdown", help="Print the Markdown reference for a CLI.")
|
|
46
|
+
md.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
|
|
47
|
+
md.add_argument(
|
|
48
|
+
"--max-chars",
|
|
49
|
+
type=int,
|
|
50
|
+
default=0,
|
|
51
|
+
help="Print only an index when the reference is longer than this (default: 0 = no limit)",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
skill = sub.add_parser("skill", help="Create a SKILL.md, or refresh its generated block.")
|
|
55
|
+
skill.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
|
|
56
|
+
skill.add_argument(
|
|
57
|
+
"--name", help="Skill name: lowercase letters, digits, hyphens (needed to create the file)"
|
|
58
|
+
)
|
|
59
|
+
skill.add_argument("--description", help="Skill description (default: from the CLI help)")
|
|
60
|
+
skill.add_argument(
|
|
61
|
+
"--output",
|
|
62
|
+
"-o",
|
|
63
|
+
type=Path,
|
|
64
|
+
help="SKILL.md path (default: skills/<name>/SKILL.md)",
|
|
65
|
+
)
|
|
66
|
+
skill.add_argument(
|
|
67
|
+
"--max-chars",
|
|
68
|
+
type=int,
|
|
69
|
+
help=f"Above this size, keep an index in SKILL.md and the full reference in {REFERENCE_FILE} "
|
|
70
|
+
f"(default: {SKILL_MAX_CHARS}, or the value stored in the file; 0 = never split)",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
chk = sub.add_parser("check", help="Exit 1 if a SKILL.md has drifted from the CLI.")
|
|
74
|
+
chk.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
|
|
75
|
+
chk.add_argument("path", type=Path, help="SKILL.md to check")
|
|
76
|
+
chk.add_argument("--max-chars", type=int, help="Override the value stored in the file")
|
|
77
|
+
for p in (md, skill, chk):
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"--program",
|
|
80
|
+
help="Program name users type (default: stored in the file, else the console script "
|
|
81
|
+
"that points at TARGET, else the CLI's own name)",
|
|
82
|
+
)
|
|
83
|
+
p.add_argument(
|
|
84
|
+
"--hide-default",
|
|
85
|
+
action="append",
|
|
86
|
+
metavar="NAME",
|
|
87
|
+
dest="hide_defaults",
|
|
88
|
+
help="Leave out the default of this parameter (name or option), e.g. one computed at "
|
|
89
|
+
"run time; repeatable. Stored in the file.",
|
|
90
|
+
)
|
|
91
|
+
p.add_argument(
|
|
92
|
+
"--app-dir",
|
|
93
|
+
metavar="DIR",
|
|
94
|
+
help="Import TARGET from this directory, e.g. src (default: the current directory)",
|
|
95
|
+
)
|
|
96
|
+
p.add_argument(
|
|
97
|
+
"--resolve",
|
|
98
|
+
metavar="MODULE:FUNC",
|
|
99
|
+
help="Function that turns a lazy placeholder command into the real command",
|
|
100
|
+
)
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_target(spec: str, *, call: bool = True, app_dir: str | None = None) -> Any:
|
|
105
|
+
"""Import ``module:attr``; ``module:func()`` calls ``func`` (e.g. a parser factory)."""
|
|
106
|
+
module_name, _, attr = spec.partition(":")
|
|
107
|
+
if not module_name or not attr or "/" in module_name or module_name.endswith(".py"):
|
|
108
|
+
raise UsageError(f"target must look like module.path:attribute, got {spec!r}")
|
|
109
|
+
explicit_call = attr.endswith("()")
|
|
110
|
+
attr = attr.removesuffix("()")
|
|
111
|
+
for directory in (os.getcwd(), app_dir):
|
|
112
|
+
if directory and os.path.abspath(directory) not in sys.path:
|
|
113
|
+
sys.path.insert(0, os.path.abspath(directory))
|
|
114
|
+
# argparse names an unnamed parser after sys.argv[0]; make that detectable.
|
|
115
|
+
saved_argv = sys.argv
|
|
116
|
+
sys.argv = [UNNAMED, *sys.argv[1:]]
|
|
117
|
+
try:
|
|
118
|
+
try:
|
|
119
|
+
obj: Any = importlib.import_module(module_name)
|
|
120
|
+
except ModuleNotFoundError as exc:
|
|
121
|
+
raise UsageError(
|
|
122
|
+
f"cannot import {module_name!r}: {exc}{_src_hint(module_name, app_dir)}"
|
|
123
|
+
) from exc
|
|
124
|
+
for part in attr.split("."):
|
|
125
|
+
if not hasattr(obj, part):
|
|
126
|
+
names = [n for n in dir(obj) if not n.startswith("_")]
|
|
127
|
+
close = difflib.get_close_matches(part, names, n=1)
|
|
128
|
+
hint = f"; did you mean {close[0]!r}?" if close else ""
|
|
129
|
+
raise UsageError(f"{module_name!r} has no attribute {attr!r}{hint}")
|
|
130
|
+
obj = getattr(obj, part)
|
|
131
|
+
if call and explicit_call:
|
|
132
|
+
obj = obj()
|
|
133
|
+
elif call and callable(obj) and not _is_cli_object(obj):
|
|
134
|
+
if "parser" not in attr.rsplit(".", 1)[-1].lower():
|
|
135
|
+
raise UsageError(
|
|
136
|
+
f"{spec} is a function, not a Click command, Typer app or ArgumentParser. "
|
|
137
|
+
f"If it returns a parser, write {spec}() to call it."
|
|
138
|
+
)
|
|
139
|
+
obj = obj()
|
|
140
|
+
except SystemExit as exc:
|
|
141
|
+
raise UsageError(f"importing {spec} exited (status {exc.code}); point at the CLI object") from exc
|
|
142
|
+
finally:
|
|
143
|
+
sys.argv = saved_argv
|
|
144
|
+
return obj
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _src_hint(module_name: str, app_dir: str | None) -> str:
|
|
148
|
+
top = module_name.split(".")[0]
|
|
149
|
+
if app_dir is None and (Path("src") / top).exists():
|
|
150
|
+
return "; the package is under src/: add --app-dir src or install the project (pip install -e .)"
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_cli_object(obj: Any) -> bool:
|
|
155
|
+
return (
|
|
156
|
+
isinstance(obj, argparse.ArgumentParser)
|
|
157
|
+
or hasattr(obj, "registered_commands")
|
|
158
|
+
or (hasattr(obj, "params") and hasattr(obj, "make_context"))
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def entry_point_name(spec: str) -> str | None:
|
|
163
|
+
"""Name of the installed console script that runs ``spec`` (``module:attr``), if any."""
|
|
164
|
+
from importlib.metadata import entry_points
|
|
165
|
+
|
|
166
|
+
target = spec.removesuffix("()").replace(" ", "")
|
|
167
|
+
for ep in entry_points(group="console_scripts"):
|
|
168
|
+
if ep.value.replace(" ", "") == target:
|
|
169
|
+
return ep.name
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: list[str] | None = None) -> int:
|
|
174
|
+
args = build_parser().parse_args(argv)
|
|
175
|
+
try:
|
|
176
|
+
return _run(args)
|
|
177
|
+
except UsageError as exc:
|
|
178
|
+
print(f"help2skill: error: {exc}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
except (ValueError, TypeError, FileNotFoundError) as exc:
|
|
181
|
+
print(f"help2skill: error: {exc}", file=sys.stderr)
|
|
182
|
+
return 2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run(args: argparse.Namespace) -> int:
|
|
186
|
+
cli = load_target(args.target, app_dir=args.app_dir)
|
|
187
|
+
resolve = load_target(args.resolve, call=False, app_dir=args.app_dir) if args.resolve else None
|
|
188
|
+
stored = None
|
|
189
|
+
if args.action == "skill" and args.output and args.output.exists():
|
|
190
|
+
stored = read_settings(args.output.read_text(encoding="utf-8")).program
|
|
191
|
+
if args.action == "check" and args.path.exists():
|
|
192
|
+
stored = read_settings(args.path.read_text(encoding="utf-8")).program
|
|
193
|
+
program = program_name(cli, args.program, stored, entry_point_name(args.target), resolve=resolve)
|
|
194
|
+
|
|
195
|
+
if args.action == "markdown":
|
|
196
|
+
if program is None:
|
|
197
|
+
_warn_unnamed(introspect(cli, resolve=resolve).name)
|
|
198
|
+
cmd = introspect(cli, program, resolve=resolve, hide_defaults=args.hide_defaults)
|
|
199
|
+
text = redact_home(render_llms(cmd, max_chars=args.max_chars))
|
|
200
|
+
_warn_machine_paths(text, "output")
|
|
201
|
+
sys.stdout.write(text)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
if args.action == "skill":
|
|
205
|
+
if args.output is None and not args.name:
|
|
206
|
+
raise UsageError("pass --name (to create skills/<name>/SKILL.md) or --output PATH")
|
|
207
|
+
output = args.output or Path("skills") / args.name / "SKILL.md"
|
|
208
|
+
path = write_skill(
|
|
209
|
+
cli,
|
|
210
|
+
output,
|
|
211
|
+
name=args.name,
|
|
212
|
+
description=args.description,
|
|
213
|
+
program=program,
|
|
214
|
+
max_chars=args.max_chars,
|
|
215
|
+
resolve=resolve,
|
|
216
|
+
hide_defaults=args.hide_defaults,
|
|
217
|
+
)
|
|
218
|
+
print(f"wrote {path}")
|
|
219
|
+
reference = path.parent / REFERENCE_FILE
|
|
220
|
+
if reference.exists():
|
|
221
|
+
print(f"wrote {reference}")
|
|
222
|
+
for written in (path, reference):
|
|
223
|
+
if written.exists():
|
|
224
|
+
_warn_machine_paths(written.read_text(encoding="utf-8"), str(written))
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
problems = check_skill_file(
|
|
228
|
+
cli,
|
|
229
|
+
args.path,
|
|
230
|
+
program=program,
|
|
231
|
+
max_chars=args.max_chars,
|
|
232
|
+
resolve=resolve,
|
|
233
|
+
hide_defaults=args.hide_defaults,
|
|
234
|
+
)
|
|
235
|
+
for problem in problems:
|
|
236
|
+
print(f"{args.path}: {problem}", file=sys.stderr)
|
|
237
|
+
if any(p.kind in _STALE_KINDS for p in problems):
|
|
238
|
+
print(f"help2skill: regenerate with: help2skill skill {args.target} -o {args.path}", file=sys.stderr)
|
|
239
|
+
if problems:
|
|
240
|
+
return 1
|
|
241
|
+
print(f"{args.path}: in sync with {args.target}")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _warn_unnamed(name: str) -> None:
|
|
246
|
+
shown = "the script name" if name == UNNAMED else repr(name)
|
|
247
|
+
print(
|
|
248
|
+
f"help2skill: warning: program name unknown (the CLI calls itself {shown}); "
|
|
249
|
+
"pass --program NAME or install the console script",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _warn_machine_paths(text: str, where: str) -> None:
|
|
255
|
+
for found in find_machine_paths(text):
|
|
256
|
+
print(
|
|
257
|
+
f"help2skill: warning: {where} contains a machine-specific path {found!r}; "
|
|
258
|
+
"review it before committing",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__": # pragma: no cover
|
|
264
|
+
raise SystemExit(main())
|
help2skill/click.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Click integration: a ``--llms`` flag that prints the Markdown reference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# No top-level ``import click``: Typer >= 0.26 vendors Click, so a Typer app may run
|
|
10
|
+
# without the ``click`` package installed.
|
|
11
|
+
from ._click import from_context
|
|
12
|
+
from .render import LLMS_MAX_CHARS, render_llms
|
|
13
|
+
|
|
14
|
+
LLMS_HELP = "Print a Markdown reference of this command for LLM agents, then exit."
|
|
15
|
+
MAX_CHARS_ENV = "HELP2SKILL_LLMS_MAX_CHARS"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_llms(ctx: Any, param: Any, value: bool) -> None:
|
|
19
|
+
"""Eager option callback usable with Click and Typer-built commands."""
|
|
20
|
+
if not value or ctx.resilient_parsing:
|
|
21
|
+
return
|
|
22
|
+
text = render_llms(
|
|
23
|
+
from_context(ctx), max_chars=_max_chars(), subcommand_flag=_subcommands_have_llms(ctx.command)
|
|
24
|
+
)
|
|
25
|
+
sys.stdout.write(text)
|
|
26
|
+
sys.stdout.flush()
|
|
27
|
+
ctx.exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _subcommands_have_llms(cmd: Any) -> bool:
|
|
31
|
+
subs = list((getattr(cmd, "commands", None) or {}).values())
|
|
32
|
+
return bool(subs) and all(any(getattr(p, "name", None) == "llms" for p in s.params) for s in subs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _max_chars() -> int:
|
|
36
|
+
"""Output budget for ``--llms``; set the env var to 0 for the full reference."""
|
|
37
|
+
try:
|
|
38
|
+
return int(os.environ.get(MAX_CHARS_ENV, LLMS_MAX_CHARS))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return LLMS_MAX_CHARS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def llms_option(*param_decls: str, **kwargs: Any):
|
|
44
|
+
"""Decorator adding ``--llms`` to one Click command or group.
|
|
45
|
+
|
|
46
|
+
On a group it prints the whole subtree, so putting it on the root is enough
|
|
47
|
+
for most tools. Use :func:`add_llms_option` to add it at every level.
|
|
48
|
+
"""
|
|
49
|
+
import click
|
|
50
|
+
|
|
51
|
+
kwargs.setdefault("help", LLMS_HELP)
|
|
52
|
+
return click.option(
|
|
53
|
+
*(param_decls or ("--llms",)),
|
|
54
|
+
is_flag=True,
|
|
55
|
+
expose_value=False,
|
|
56
|
+
is_eager=True,
|
|
57
|
+
callback=print_llms,
|
|
58
|
+
**kwargs,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def add_llms_option(cmd: Any, *, recursive: bool = True) -> Any:
|
|
63
|
+
"""Append ``--llms`` to an already-built command (and its subcommands).
|
|
64
|
+
|
|
65
|
+
Works for Typer-built commands too, whose classes come from Typer's
|
|
66
|
+
vendored copy of Click.
|
|
67
|
+
"""
|
|
68
|
+
option_cls = _option_class(cmd)
|
|
69
|
+
for node in _iter_commands(cmd) if recursive else [cmd]:
|
|
70
|
+
if any(getattr(p, "name", None) == "llms" for p in node.params):
|
|
71
|
+
continue
|
|
72
|
+
node.params.append(
|
|
73
|
+
option_cls(
|
|
74
|
+
param_decls=["--llms"],
|
|
75
|
+
is_flag=True,
|
|
76
|
+
expose_value=False,
|
|
77
|
+
is_eager=True,
|
|
78
|
+
callback=print_llms,
|
|
79
|
+
help=LLMS_HELP,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return cmd
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _option_class(cmd: Any) -> Any:
|
|
86
|
+
if type(cmd).__module__.startswith("typer"):
|
|
87
|
+
from typer.core import TyperOption
|
|
88
|
+
|
|
89
|
+
return TyperOption
|
|
90
|
+
import click
|
|
91
|
+
|
|
92
|
+
return click.Option
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _iter_commands(cmd: Any):
|
|
96
|
+
yield cmd
|
|
97
|
+
for sub in (getattr(cmd, "commands", None) or {}).values():
|
|
98
|
+
yield from _iter_commands(sub)
|
help2skill/model.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Framework-neutral description of a command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Param:
|
|
11
|
+
"""One option or positional argument."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
kind: str # "option" or "argument"
|
|
15
|
+
opts: list[str] = field(default_factory=list)
|
|
16
|
+
secondary_opts: list[str] = field(default_factory=list)
|
|
17
|
+
type: str = "text"
|
|
18
|
+
choices: list[str] | None = None
|
|
19
|
+
required: bool = False
|
|
20
|
+
default: str | None = None
|
|
21
|
+
is_flag: bool = False
|
|
22
|
+
multiple: bool = False
|
|
23
|
+
nargs: int | str = 1
|
|
24
|
+
envvars: list[str] = field(default_factory=list)
|
|
25
|
+
help: str | None = None
|
|
26
|
+
metavar: str | None = None
|
|
27
|
+
# One token per value slot, e.g. ["NAME", "[integer|text]"] for a 2-tuple option.
|
|
28
|
+
value_tokens: list[str] | None = None
|
|
29
|
+
count: bool = False # -v, -vv, -vvv
|
|
30
|
+
range: str | None = None # e.g. "1<=x<=50"
|
|
31
|
+
case_insensitive: bool = False
|
|
32
|
+
prompts: bool = False # asks interactively when omitted
|
|
33
|
+
optional_value: bool = False # option may be given without its value
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_option(self) -> bool:
|
|
37
|
+
return self.kind == "option"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Command:
|
|
42
|
+
"""A command or command group, with its full invocation path."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
path: list[str]
|
|
46
|
+
help: str | None = None
|
|
47
|
+
short_help: str | None = None
|
|
48
|
+
params: list[Param] = field(default_factory=list)
|
|
49
|
+
subcommands: list[Command] = field(default_factory=list)
|
|
50
|
+
deprecated: bool = False
|
|
51
|
+
# Subcommand run when the first token is not a subcommand name (click-default-group).
|
|
52
|
+
default_command: str | None = None
|
|
53
|
+
aliases: list[str] = field(default_factory=list)
|
|
54
|
+
# Names that run a command but are not documented (hidden commands).
|
|
55
|
+
hidden_subcommands: list[str] = field(default_factory=list)
|
|
56
|
+
# Groups of options that cannot be combined: (option names, one is required).
|
|
57
|
+
exclusive: list[tuple[list[str], bool]] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_group(self) -> bool:
|
|
61
|
+
return bool(self.subcommands)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def options(self) -> list[Param]:
|
|
65
|
+
return [p for p in self.params if p.is_option]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def arguments(self) -> list[Param]:
|
|
69
|
+
return [p for p in self.params if not p.is_option]
|
|
70
|
+
|
|
71
|
+
def walk(self) -> Iterator[Command]:
|
|
72
|
+
"""Yield this command and every descendant, depth first."""
|
|
73
|
+
yield self
|
|
74
|
+
for sub in self.subcommands:
|
|
75
|
+
yield from sub.walk()
|
|
76
|
+
|
|
77
|
+
def get(self, name: str) -> Command | None:
|
|
78
|
+
"""Find a direct subcommand by name or alias."""
|
|
79
|
+
for sub in self.subcommands:
|
|
80
|
+
if sub.name == name or name in sub.aliases:
|
|
81
|
+
return sub
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def find(self, path: list[str]) -> Command | None:
|
|
85
|
+
"""Resolve a list of subcommand names below this command."""
|
|
86
|
+
node: Command | None = self
|
|
87
|
+
for part in path:
|
|
88
|
+
node = node.get(part) if node else None
|
|
89
|
+
return node
|
help2skill/privacy.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Keep machine-specific paths out of generated files.
|
|
2
|
+
|
|
3
|
+
Help text is sometimes computed at runtime (``default: /Users/alice/.cache/...``).
|
|
4
|
+
Written files replace the current user's home directory with ``~``, which also keeps
|
|
5
|
+
the drift check stable between a laptop and CI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_MACHINE_PATH_RE = re.compile(r"(/Users/[^/\s`'\"]+|/home/[^/\s`'\"]+|[A-Za-z]:\\Users\\[^\\\s`'\"]+)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _home_variants(home: str | None) -> list[str]:
|
|
18
|
+
home = home or str(Path.home())
|
|
19
|
+
variants = {home, os.path.realpath(home)}
|
|
20
|
+
return sorted((v.rstrip("/\\") for v in variants if len(v) > 1), key=len, reverse=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def redact_home(text: str, home: str | None = None) -> str:
|
|
24
|
+
"""Replace the home directory (``home`` or the current user's) with ``~``."""
|
|
25
|
+
for variant in _home_variants(home):
|
|
26
|
+
# Not followed by a name character, so /home/alice does not match /home/alicex.
|
|
27
|
+
text = re.sub(re.escape(variant) + r"(?![\w-])", "~", text)
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_machine_paths(text: str) -> list[str]:
|
|
32
|
+
"""Return home-directory-like paths left in ``text``, e.g. another user's."""
|
|
33
|
+
return sorted(set(_MACHINE_PATH_RE.findall(text)))
|
help2skill/py.typed
ADDED
|
File without changes
|