fherma 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.
- fherma/__init__.py +0 -0
- fherma/__main__.py +4 -0
- fherma/api.py +107 -0
- fherma/cli.py +89 -0
- fherma/commands/__init__.py +1 -0
- fherma/commands/auth.py +57 -0
- fherma/commands/testing.py +187 -0
- fherma/commands/toolchain.py +164 -0
- fherma/config.py +93 -0
- fherma/errors.py +52 -0
- fherma/output.py +78 -0
- fherma/project.py +78 -0
- fherma/py.typed +0 -0
- fherma/toolchain.py +100 -0
- fherma-0.1.0.dist-info/METADATA +160 -0
- fherma-0.1.0.dist-info/RECORD +19 -0
- fherma-0.1.0.dist-info/WHEEL +4 -0
- fherma-0.1.0.dist-info/entry_points.txt +6 -0
- fherma-0.1.0.dist-info/licenses/LICENSE +202 -0
fherma/__init__.py
ADDED
|
File without changes
|
fherma/__main__.py
ADDED
fherma/api.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""A thin client over the platform's API.
|
|
2
|
+
|
|
3
|
+
Scaffolding is not here: it is a function of a signature, and `fherma_lang`
|
|
4
|
+
computes it in this process. The platform is asked for the signature and for
|
|
5
|
+
what only it knows — versions, points, status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .config import Profile
|
|
16
|
+
from .errors import AuthError, NetworkError, UsageError
|
|
17
|
+
|
|
18
|
+
#: Bumped when the wire contract changes. The server answers with the range it
|
|
19
|
+
#: speaks, so an outdated client is told to update rather than shown a 400.
|
|
20
|
+
API_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SpecRef:
|
|
25
|
+
"""`kernel/spec@version`, with the version optional."""
|
|
26
|
+
|
|
27
|
+
kernel: str
|
|
28
|
+
spec: str
|
|
29
|
+
version: str | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def parse(cls, text: str) -> SpecRef:
|
|
33
|
+
body, _, version = text.partition("@")
|
|
34
|
+
kernel, sep, spec = body.partition("/")
|
|
35
|
+
if not sep or not kernel or not spec:
|
|
36
|
+
raise UsageError(
|
|
37
|
+
f"{text!r} is not a specification reference",
|
|
38
|
+
hint="Write it as kernel/spec@version, for example polymult/rlwe@1.0.0",
|
|
39
|
+
)
|
|
40
|
+
return cls(kernel=kernel, spec=spec, version=version or None)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def path(self) -> str:
|
|
44
|
+
tail = f"@{self.version}" if self.version else ""
|
|
45
|
+
return f"/specs/{self.kernel}/{self.spec}{tail}"
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
tail = f"@{self.version}" if self.version else ""
|
|
49
|
+
return f"{self.kernel}/{self.spec}{tail}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Api:
|
|
53
|
+
def __init__(self, profile: Profile) -> None:
|
|
54
|
+
self._profile = profile
|
|
55
|
+
headers = {"accept": "application/json", "x-fherma-api": str(API_VERSION)}
|
|
56
|
+
if profile.token:
|
|
57
|
+
headers["authorization"] = f"Bearer {profile.token}"
|
|
58
|
+
self._client = httpx.Client(
|
|
59
|
+
base_url=f"{profile.url.rstrip('/')}/api",
|
|
60
|
+
headers=headers,
|
|
61
|
+
timeout=30.0,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self._client.close()
|
|
66
|
+
|
|
67
|
+
# ── plumbing ────────────────────────────────────────────────────────────
|
|
68
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
69
|
+
try:
|
|
70
|
+
response = self._client.request(method, path, **kwargs)
|
|
71
|
+
except httpx.RequestError as error:
|
|
72
|
+
raise NetworkError(
|
|
73
|
+
f"cannot reach {self._profile.url}",
|
|
74
|
+
hint=f"{error.__class__.__name__}: {error}",
|
|
75
|
+
) from error
|
|
76
|
+
|
|
77
|
+
if response.status_code in (401, 403):
|
|
78
|
+
raise AuthError(
|
|
79
|
+
"not authorised",
|
|
80
|
+
hint="Run `fherma auth login`, or set FHERMA_TOKEN.",
|
|
81
|
+
)
|
|
82
|
+
if response.status_code == 404:
|
|
83
|
+
raise UsageError(f"not found: {path}")
|
|
84
|
+
if response.status_code == 426:
|
|
85
|
+
raise NetworkError(
|
|
86
|
+
"this version of the CLI is too old for the platform",
|
|
87
|
+
hint="Run `pip install --upgrade fherma`.",
|
|
88
|
+
)
|
|
89
|
+
if response.status_code >= 400:
|
|
90
|
+
raise NetworkError(
|
|
91
|
+
f"the platform answered {response.status_code}",
|
|
92
|
+
hint=_short(response.text),
|
|
93
|
+
)
|
|
94
|
+
return response.json() if response.content else None
|
|
95
|
+
|
|
96
|
+
# ── endpoints ───────────────────────────────────────────────────────────
|
|
97
|
+
def whoami(self) -> dict:
|
|
98
|
+
return self._request("GET", "/auth/me")
|
|
99
|
+
|
|
100
|
+
def spec(self, ref: SpecRef) -> dict:
|
|
101
|
+
"""Specification data: signature, description, points, status."""
|
|
102
|
+
return self._request("GET", ref.path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _short(text: str, limit: int = 200) -> str:
|
|
106
|
+
text = " ".join(text.split())
|
|
107
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
fherma/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Entry point.
|
|
2
|
+
|
|
3
|
+
Command groups are not listed here. They are discovered through the
|
|
4
|
+
`fherma.commands` entry point group, so adding a noun means adding a module and
|
|
5
|
+
one line to `pyproject.toml` — and a separate distribution can add one without
|
|
6
|
+
touching this project at all.
|
|
7
|
+
|
|
8
|
+
`doctor` and `completion` are the exception, attached below by hand. They are
|
|
9
|
+
not nouns the platform has; they are about the installation, and they have to
|
|
10
|
+
keep working on the day discovery itself is what broke.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from importlib.metadata import entry_points
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from . import config, output, toolchain
|
|
21
|
+
from .api import Api
|
|
22
|
+
from .commands.toolchain import completion, doctor
|
|
23
|
+
from .errors import Exit, FhermaError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Context:
|
|
27
|
+
"""Shared state. Commands reach it through `click.pass_obj`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, profile_name: str | None) -> None:
|
|
30
|
+
self.profile = config.load(profile_name)
|
|
31
|
+
self._api: Api | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def api(self) -> Api:
|
|
35
|
+
# Built lazily so that offline commands — `check`, `case` — never open a
|
|
36
|
+
# connection and never need a token.
|
|
37
|
+
if self._api is None:
|
|
38
|
+
self._api = Api(self.profile)
|
|
39
|
+
return self._api
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
43
|
+
"""`--version` reports the toolchain, not this package.
|
|
44
|
+
|
|
45
|
+
An install is two distributions that move independently, and a bug report
|
|
46
|
+
quoting only one of them is a bug report missing half its evidence.
|
|
47
|
+
"""
|
|
48
|
+
if not value or ctx.resilient_parsing:
|
|
49
|
+
return
|
|
50
|
+
click.echo(toolchain.describe())
|
|
51
|
+
ctx.exit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
55
|
+
@click.option("-V", "--version", is_flag=True, is_eager=True, expose_value=False,
|
|
56
|
+
callback=_print_version, help="Show the version of every installed part.")
|
|
57
|
+
@click.option("--profile", envvar="FHERMA_PROFILE",
|
|
58
|
+
help="Which configured platform to talk to.")
|
|
59
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable output.")
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def cli(ctx: click.Context, profile: str | None, as_json: bool) -> None:
|
|
62
|
+
"""FHERMA — the kernel catalogue, from the command line."""
|
|
63
|
+
output.set_json(as_json)
|
|
64
|
+
ctx.obj = Context(profile)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
cli.add_command(doctor)
|
|
68
|
+
cli.add_command(completion)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _install_groups() -> None:
|
|
72
|
+
for entry in entry_points(group="fherma.commands"):
|
|
73
|
+
cli.add_command(entry.load(), name=entry.name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
_install_groups()
|
|
78
|
+
try:
|
|
79
|
+
cli.main(standalone_mode=False)
|
|
80
|
+
except FhermaError as error:
|
|
81
|
+
output.fail(error.message, error.hint)
|
|
82
|
+
sys.exit(int(error.exit_code))
|
|
83
|
+
except click.ClickException as error:
|
|
84
|
+
error.show()
|
|
85
|
+
sys.exit(int(Exit.USAGE))
|
|
86
|
+
except click.Abort:
|
|
87
|
+
output.fail("aborted")
|
|
88
|
+
sys.exit(int(Exit.USAGE))
|
|
89
|
+
sys.exit(int(Exit.OK))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command groups, discovered through entry points."""
|
fherma/commands/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Who we are to the platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import config, output
|
|
8
|
+
from ..api import Api
|
|
9
|
+
from ..errors import AuthError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def group() -> None:
|
|
14
|
+
"""Log in, log out, and see who you are."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@group.command()
|
|
18
|
+
@click.option("--token", prompt="API token", hide_input=True,
|
|
19
|
+
help="Read from the terminal when not given, so it stays out of shell history.")
|
|
20
|
+
@click.pass_obj
|
|
21
|
+
def login(ctx, token: str) -> None:
|
|
22
|
+
"""Store a token for this profile."""
|
|
23
|
+
# Verify before writing. A token that is saved and then turns out to be
|
|
24
|
+
# wrong is worse than one that was never saved: the failure surfaces later,
|
|
25
|
+
# somewhere else.
|
|
26
|
+
probe = Api(config.Profile(name=ctx.profile.name, url=ctx.profile.url, token=token))
|
|
27
|
+
try:
|
|
28
|
+
me = probe.whoami()
|
|
29
|
+
finally:
|
|
30
|
+
probe.close()
|
|
31
|
+
|
|
32
|
+
config.save_token(ctx.profile.name, ctx.profile.url, token)
|
|
33
|
+
output.ok(f"logged in as {me.get('handle', 'unknown')} on {ctx.profile.name}")
|
|
34
|
+
output.emit({"profile": ctx.profile.name, "handle": me.get("handle")})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@group.command()
|
|
38
|
+
@click.pass_obj
|
|
39
|
+
def logout(ctx) -> None:
|
|
40
|
+
"""Forget the stored token for this profile."""
|
|
41
|
+
if config.forget_token(ctx.profile.name):
|
|
42
|
+
output.ok(f"token removed from profile {ctx.profile.name}")
|
|
43
|
+
else:
|
|
44
|
+
output.note(f"no token stored for profile {ctx.profile.name}")
|
|
45
|
+
output.emit({"profile": ctx.profile.name, "token": None})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@group.command()
|
|
49
|
+
@click.pass_obj
|
|
50
|
+
def whoami(ctx) -> None:
|
|
51
|
+
"""Show the account this profile is using."""
|
|
52
|
+
if not ctx.profile.token:
|
|
53
|
+
raise AuthError("no token for this profile", hint="Run `fherma auth login`.")
|
|
54
|
+
me = ctx.api.whoami()
|
|
55
|
+
output.say(f"{me.get('handle')} — {me.get('display_name', '')}")
|
|
56
|
+
output.note(f"{ctx.profile.name} · {ctx.profile.url}")
|
|
57
|
+
output.emit(me)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The generator, the oracle and the verifier of a specification.
|
|
2
|
+
|
|
3
|
+
These three say where test data comes from, what the right answer is, and how a
|
|
4
|
+
verdict is reached. Their shapes are the same for every kernel in the catalogue;
|
|
5
|
+
only the types differ, and those come from the specification's signature.
|
|
6
|
+
|
|
7
|
+
The scaffold is produced by `fherma-lang`, in this process. The platform is
|
|
8
|
+
asked for one thing — the signature — so `init` needs no server at all when the
|
|
9
|
+
signature is already on disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from fherma_lang import LanguageError, emit, parse, reference
|
|
18
|
+
from fherma_lang.emit import PARTS
|
|
19
|
+
|
|
20
|
+
from .. import output, project
|
|
21
|
+
from ..api import SpecRef
|
|
22
|
+
from ..errors import CheckFailed, UsageError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
def group() -> None:
|
|
27
|
+
"""Write and check the generator, oracle and verifier."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@group.command(short_help="fetch the bundle to write them in")
|
|
31
|
+
@click.argument("spec", required=False, metavar="KERNEL/SPEC[@VERSION]")
|
|
32
|
+
@click.option("--signature", type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
33
|
+
help="Scaffold from a signature on disk instead of asking the platform.")
|
|
34
|
+
@click.option("--generator", "wanted", flag_value="generator", multiple=True,
|
|
35
|
+
help="Scaffold the generator.")
|
|
36
|
+
@click.option("--oracle", "wanted", flag_value="oracle", multiple=True,
|
|
37
|
+
help="Scaffold the oracle.")
|
|
38
|
+
@click.option("--verifier", "wanted", flag_value="verifier", multiple=True,
|
|
39
|
+
help="Scaffold the verifier. All three when none is named.")
|
|
40
|
+
@click.option("--out", type=click.Path(file_okay=False, path_type=Path), metavar="DIR",
|
|
41
|
+
help="Where to write. Named after the specification when omitted.")
|
|
42
|
+
@click.option("--force", is_flag=True, help="Overwrite files that are already there.")
|
|
43
|
+
@click.option("--update", is_flag=True,
|
|
44
|
+
help="Rewrite the generated files only, leaving yours untouched.")
|
|
45
|
+
@click.pass_obj
|
|
46
|
+
def init(ctx, spec: str | None, signature: Path | None, wanted: tuple[str, ...],
|
|
47
|
+
out: Path | None, force: bool, update: bool) -> None:
|
|
48
|
+
"""Fetch the bundle for a specification.
|
|
49
|
+
|
|
50
|
+
\b
|
|
51
|
+
fherma testing init polymult/rlwe@1.0.0
|
|
52
|
+
fherma testing init polymult/rlwe # the latest published version
|
|
53
|
+
fherma testing init polymult/rlwe --oracle --verifier
|
|
54
|
+
fherma testing init --signature ./sig.fhk # no platform involved
|
|
55
|
+
"""
|
|
56
|
+
if bool(spec) == bool(signature):
|
|
57
|
+
raise UsageError(
|
|
58
|
+
"name a specification, or pass --signature",
|
|
59
|
+
hint="fherma testing init polymult/rlwe@1.0.0",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
ref: SpecRef | None = None
|
|
63
|
+
if signature:
|
|
64
|
+
text = signature.read_text(encoding="utf-8")
|
|
65
|
+
else:
|
|
66
|
+
ref = SpecRef.parse(spec)
|
|
67
|
+
data = ctx.api.spec(ref)
|
|
68
|
+
text = (data.get("signature") or "").strip()
|
|
69
|
+
if not text:
|
|
70
|
+
raise UsageError(
|
|
71
|
+
f"{ref} has no signature yet",
|
|
72
|
+
hint="A specification is scaffolded from its signature; this one is empty.",
|
|
73
|
+
)
|
|
74
|
+
# The reference may have arrived without a version; report the resolved one.
|
|
75
|
+
ref = SpecRef(kernel=data.get("kernel", ref.kernel),
|
|
76
|
+
spec=data.get("slug", ref.spec),
|
|
77
|
+
version=data.get("version", ref.version))
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
parts = [p for p in PARTS if p in wanted] or None
|
|
81
|
+
declaration = parse(text)
|
|
82
|
+
files = emit(declaration, kind="testing", parts=parts)
|
|
83
|
+
except LanguageError as failure:
|
|
84
|
+
diagnostic = failure.diagnostic
|
|
85
|
+
raise CheckFailed(f"{diagnostic.code}: {diagnostic.message}",
|
|
86
|
+
hint=diagnostic.hint) from failure
|
|
87
|
+
|
|
88
|
+
# A bundle scaffolded from a local file is still a bundle: later commands
|
|
89
|
+
# have to recognise it. The declaration names itself, so the marker never
|
|
90
|
+
# depends on having asked the platform.
|
|
91
|
+
marker = str(ref) if ref else reference(declaration)
|
|
92
|
+
|
|
93
|
+
root = (out or Path(_directory(ref, signature))).resolve()
|
|
94
|
+
written, kept = _write(root, files, force=force, update=update)
|
|
95
|
+
|
|
96
|
+
project.write(root, kind="testing", spec=marker, fingerprint=_fingerprint(text))
|
|
97
|
+
|
|
98
|
+
output.ok(f"{'updated' if update else 'scaffolded'} {root}")
|
|
99
|
+
for entry in written:
|
|
100
|
+
output.note(f" {entry}")
|
|
101
|
+
if kept:
|
|
102
|
+
output.note(f" kept: {', '.join(kept)}")
|
|
103
|
+
|
|
104
|
+
if not update:
|
|
105
|
+
output.say("")
|
|
106
|
+
if parts:
|
|
107
|
+
rest = [f"--{p}" for p in PARTS if p not in parts]
|
|
108
|
+
output.say(f"Only the {', '.join(parts)}."
|
|
109
|
+
+ (f" Add [bold]{' '.join(rest)}[/bold] for the rest." if rest else ""))
|
|
110
|
+
else:
|
|
111
|
+
output.say("Write [bold]generate.py[/bold] and [bold]oracle.py[/bold], "
|
|
112
|
+
"then run [bold]fherma testing check[/bold].")
|
|
113
|
+
|
|
114
|
+
output.emit({
|
|
115
|
+
"root": str(root),
|
|
116
|
+
"spec": marker,
|
|
117
|
+
"parts": list(parts or PARTS),
|
|
118
|
+
"written": written,
|
|
119
|
+
"kept": kept,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@group.command(short_help="what the platform thinks of this bundle")
|
|
124
|
+
@click.pass_obj
|
|
125
|
+
def status(ctx) -> None:
|
|
126
|
+
"""Show the specification's state and what it is still missing."""
|
|
127
|
+
here = project.require_kind("testing")
|
|
128
|
+
ref = SpecRef.parse(here.spec)
|
|
129
|
+
data = ctx.api.spec(ref)
|
|
130
|
+
|
|
131
|
+
output.say(f"{ref} — {data.get('title', '')}")
|
|
132
|
+
output.note(f"status: {data.get('status', 'unknown')}")
|
|
133
|
+
|
|
134
|
+
signature = (data.get("signature") or "").strip()
|
|
135
|
+
if here.fingerprint and signature and _fingerprint(signature) != here.fingerprint:
|
|
136
|
+
output.bad("the specification's signature has changed since this bundle was made")
|
|
137
|
+
output.note(" run `fherma testing init --update` to refresh the generated files")
|
|
138
|
+
|
|
139
|
+
output.emit(data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── writing ─────────────────────────────────────────────────────────────────
|
|
143
|
+
def _write(root: Path, files, *, force: bool, update: bool) -> tuple[list[str], list[str]]:
|
|
144
|
+
"""Lay the bundle out.
|
|
145
|
+
|
|
146
|
+
Two rules, and both matter more than they look. A directory holding work is
|
|
147
|
+
never written into unasked; and an update rewrites only what was generated,
|
|
148
|
+
because the whole point of the split is that authored files are the author's.
|
|
149
|
+
"""
|
|
150
|
+
if root.exists() and any(root.iterdir()) and not (force or update):
|
|
151
|
+
raise UsageError(
|
|
152
|
+
f"{root} is not empty",
|
|
153
|
+
hint="Pass --force to write into it, or --update to refresh generated files only.",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
written: list[str] = []
|
|
157
|
+
kept: list[str] = []
|
|
158
|
+
|
|
159
|
+
for file in files:
|
|
160
|
+
target = root / file.path
|
|
161
|
+
authored = not file.generated
|
|
162
|
+
|
|
163
|
+
if authored and (update or (target.exists() and not force)):
|
|
164
|
+
kept.append(file.path)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
target.write_text(file.content, encoding="utf-8")
|
|
169
|
+
written.append(file.path)
|
|
170
|
+
|
|
171
|
+
for name in ("vectors", "assets"):
|
|
172
|
+
(root / name).mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
return written, kept
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _directory(ref: SpecRef | None, signature: Path | None) -> str:
|
|
178
|
+
if ref:
|
|
179
|
+
return f"{ref.kernel}-{ref.spec}" + (f"-{ref.version}" if ref.version else "")
|
|
180
|
+
return signature.stem if signature else "bundle"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _fingerprint(signature: str) -> str:
|
|
184
|
+
"""What the bundle was made from, so a later command can notice a change."""
|
|
185
|
+
import hashlib
|
|
186
|
+
|
|
187
|
+
return "sha256:" + hashlib.sha256(signature.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""`doctor` and `completion` — commands about the installation itself.
|
|
2
|
+
|
|
3
|
+
These two are registered directly on the root rather than through entry points.
|
|
4
|
+
Everything else is a noun the platform has (`auth`, `testing`); these are about
|
|
5
|
+
the tool on this machine, and they must keep working when the rest does not.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .. import config, output, toolchain
|
|
17
|
+
from ..errors import CheckFailed
|
|
18
|
+
|
|
19
|
+
#: `ok` — nothing to do. `warn` — works, but something is worth knowing.
|
|
20
|
+
#: `bad` — the toolchain is broken here, and `doctor` exits non-zero.
|
|
21
|
+
OK, WARN, BAD = "ok", "warn", "bad"
|
|
22
|
+
|
|
23
|
+
MARKS = {OK: "[green]✓[/green]", WARN: "[yellow]•[/yellow]", BAD: "[red]✗[/red]"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Check:
|
|
28
|
+
label: str
|
|
29
|
+
status: str
|
|
30
|
+
detail: str
|
|
31
|
+
hint: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _python() -> Check:
|
|
35
|
+
if not toolchain.python_is_supported():
|
|
36
|
+
least = ".".join(str(part) for part in toolchain.MINIMUM_PYTHON)
|
|
37
|
+
return Check("python", BAD, toolchain.python_version(),
|
|
38
|
+
f"FHERMA needs Python {least} or newer.")
|
|
39
|
+
# The interpreter's path goes on the hint line: it is long, it wraps badly,
|
|
40
|
+
# and it is only ever read when the answer is "not the python you think".
|
|
41
|
+
return Check("python", OK, toolchain.python_version(), sys.executable)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _packages() -> list[Check]:
|
|
45
|
+
checks = []
|
|
46
|
+
for component in toolchain.components():
|
|
47
|
+
if component.installed:
|
|
48
|
+
checks.append(Check(component.name, OK, component.version))
|
|
49
|
+
else:
|
|
50
|
+
# Only reachable when someone installed the two halves separately
|
|
51
|
+
# and got one of them; `pip install fherma` cannot produce it.
|
|
52
|
+
checks.append(Check(component.name, BAD, "not installed",
|
|
53
|
+
f"Run `pip install {component.name}`."))
|
|
54
|
+
return checks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _commands() -> Check:
|
|
58
|
+
groups = toolchain.groups()
|
|
59
|
+
if not groups:
|
|
60
|
+
return Check("commands", BAD, "none registered",
|
|
61
|
+
"Reinstall: entry points are missing from the metadata.")
|
|
62
|
+
extra = {group.name for group in toolchain.plugins()}
|
|
63
|
+
names = ", ".join(
|
|
64
|
+
f"{group.name} ({group.source})" if group.name in extra else group.name
|
|
65
|
+
for group in groups
|
|
66
|
+
)
|
|
67
|
+
return Check("commands", OK, names)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _profile(profile: config.Profile) -> list[Check]:
|
|
71
|
+
where = config.CONFIG_FILE if config.CONFIG_FILE.exists() else None
|
|
72
|
+
checks = [Check("profile", OK, f"{profile.name} → {profile.url}",
|
|
73
|
+
None if where else "No config file yet; defaults are in use.")]
|
|
74
|
+
if profile.token:
|
|
75
|
+
checks.append(Check("credentials", OK, "token present"))
|
|
76
|
+
else:
|
|
77
|
+
# Not a failure: `--signature` scaffolding never touches the platform.
|
|
78
|
+
checks.append(Check("credentials", WARN, "no token",
|
|
79
|
+
"Run `fherma auth login` when you need the platform."))
|
|
80
|
+
return checks
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _platform(context) -> Check:
|
|
84
|
+
try:
|
|
85
|
+
who = context.api.whoami()
|
|
86
|
+
except Exception as error: # noqa: BLE001 — doctor reports, never raises
|
|
87
|
+
return Check("platform", BAD, "unreachable", getattr(error, "message", str(error)))
|
|
88
|
+
name = who.get("email") or who.get("name") or "authenticated"
|
|
89
|
+
return Check("platform", OK, name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.command()
|
|
93
|
+
@click.option("--online", is_flag=True,
|
|
94
|
+
help="Also contact the platform and check the token is accepted.")
|
|
95
|
+
@click.pass_obj
|
|
96
|
+
def doctor(context, online: bool) -> None:
|
|
97
|
+
"""Check that the toolchain is installed and whole.
|
|
98
|
+
|
|
99
|
+
Offline by default, because the usual reason to run this is that something
|
|
100
|
+
is wrong, and a network timeout would bury the answer.
|
|
101
|
+
|
|
102
|
+
\b
|
|
103
|
+
Exits 1 if anything is broken. A missing token is not broken: scaffolding
|
|
104
|
+
from a local signature never needs one.
|
|
105
|
+
"""
|
|
106
|
+
checks = [_python(), *_packages(), _commands(), *_profile(context.profile)]
|
|
107
|
+
if online:
|
|
108
|
+
checks.append(_platform(context))
|
|
109
|
+
|
|
110
|
+
output.emit({
|
|
111
|
+
"checks": [{"name": c.label, "status": c.status, "detail": c.detail} for c in checks],
|
|
112
|
+
"ok": all(c.status != BAD for c in checks),
|
|
113
|
+
**toolchain.summary(),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
width = max(len(check.label) for check in checks)
|
|
117
|
+
indent = " " * (width + 4) # past the mark, the label and the gap
|
|
118
|
+
for check in checks:
|
|
119
|
+
output.say(f"{MARKS[check.status]} {check.label:<{width}} {check.detail}")
|
|
120
|
+
if check.hint:
|
|
121
|
+
output.note(f"{indent}{check.hint}")
|
|
122
|
+
|
|
123
|
+
broken = [check for check in checks if check.status == BAD]
|
|
124
|
+
if broken:
|
|
125
|
+
raise CheckFailed(
|
|
126
|
+
f"{len(broken)} of {len(checks)} checks failed",
|
|
127
|
+
hint=broken[0].hint or f"Start with: {broken[0].label}.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@click.command()
|
|
132
|
+
@click.argument("shell", required=False,
|
|
133
|
+
type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False))
|
|
134
|
+
@click.pass_context
|
|
135
|
+
def completion(context: click.Context, shell: str | None) -> None:
|
|
136
|
+
"""Print a shell completion script for `fherma`.
|
|
137
|
+
|
|
138
|
+
\b
|
|
139
|
+
Append it to the file your shell already reads:
|
|
140
|
+
fherma completion zsh >> ~/.zshrc
|
|
141
|
+
fherma completion bash >> ~/.bashrc
|
|
142
|
+
fherma completion fish > ~/.config/fish/completions/fherma.fish
|
|
143
|
+
|
|
144
|
+
The shell is guessed from $SHELL when not named.
|
|
145
|
+
"""
|
|
146
|
+
from click.shell_completion import get_completion_class
|
|
147
|
+
|
|
148
|
+
shell = (shell or _guess_shell() or "").lower()
|
|
149
|
+
if not shell:
|
|
150
|
+
raise click.UsageError("cannot tell which shell this is; name one: bash, zsh, fish")
|
|
151
|
+
|
|
152
|
+
completion_class = get_completion_class(shell)
|
|
153
|
+
if completion_class is None: # pragma: no cover — Choice has already filtered
|
|
154
|
+
raise click.UsageError(f"no completion support for {shell}")
|
|
155
|
+
|
|
156
|
+
# The root command, not this one: completion is for the whole tool. Reading
|
|
157
|
+
# it off the context avoids importing `cli`, which imports this module.
|
|
158
|
+
root = context.find_root().command
|
|
159
|
+
script = completion_class(root, {}, "fherma", "_FHERMA_COMPLETE").source()
|
|
160
|
+
click.echo(script)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _guess_shell() -> str | None:
|
|
164
|
+
return os.path.basename(os.environ.get("SHELL", "")) or None
|