sci-protocol 0.2.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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
pysci/_cli/config.py ADDED
@@ -0,0 +1,233 @@
1
+ """CLI state directory and ``config.json`` (index origin, key, folders, TSA, trust)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import urllib.parse
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pysci.errors import SciError
15
+ from pysci.jsonstrict import parse_json_object
16
+
17
+ from .errors import CliError, Exit
18
+ from .storage.files import atomic_write_bytes
19
+
20
+ CONFIG_FILE = "config.json"
21
+ CATALOG_FILE = "catalog.sqlite"
22
+ DEFAULT_KEY = "key.pem"
23
+
24
+
25
+ def state_dir(env: Mapping[str, str]) -> Path:
26
+ """``SCI_HOME`` if set, else ``~/.sci``."""
27
+ home = env.get("SCI_HOME")
28
+ if home:
29
+ return Path(home).expanduser().absolute()
30
+ return Path(env.get("HOME") or Path.home()).expanduser() / ".sci"
31
+
32
+
33
+ def ensure_state_dir(path: Path) -> Path:
34
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
35
+ return path
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class TrustSettings:
40
+ """Timestamp trust configuration.
41
+
42
+ ``freetsa`` enables the bundled FreeTSA root; ``anchors`` and ``crls`` are
43
+ local PEM/DER files; ``crl_urls`` are fetched only for online checks.
44
+ """
45
+
46
+ freetsa: bool = True
47
+ anchors: list[str] = field(default_factory=list)
48
+ crls: list[str] = field(default_factory=list)
49
+ crl_urls: list[str] = field(default_factory=list)
50
+
51
+ def to_json(self) -> dict[str, Any]:
52
+ return {
53
+ "freetsa": self.freetsa,
54
+ "anchors": list(self.anchors),
55
+ "crls": list(self.crls),
56
+ "crl_urls": list(self.crl_urls),
57
+ }
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class Config:
62
+ index: str | None = None
63
+ key: str | None = None
64
+ folders: list[str] = field(default_factory=list)
65
+ tsa_url: str | None = None
66
+ timestamp_trust: TrustSettings = field(default_factory=TrustSettings)
67
+ other: dict[str, Any] = field(default_factory=dict)
68
+
69
+ def to_json(self) -> dict[str, Any]:
70
+ out: dict[str, Any] = dict(self.other)
71
+ out.update(
72
+ {
73
+ "index": self.index,
74
+ "key": self.key,
75
+ "folders": list(self.folders),
76
+ "tsa_url": self.tsa_url,
77
+ "timestamp_trust": self.timestamp_trust.to_json(),
78
+ }
79
+ )
80
+ return out
81
+
82
+
83
+ def _config_error(field_name: str, requirement: str, observed: object) -> CliError:
84
+ return CliError(
85
+ "config_invalid",
86
+ f"{CONFIG_FILE} field {field_name}: {requirement}",
87
+ Exit.USAGE,
88
+ file=CONFIG_FILE,
89
+ field="/" + field_name.replace(".", "/"),
90
+ requirement=requirement,
91
+ observed=repr(observed)[:120],
92
+ )
93
+
94
+
95
+ def _optional_str(record: Mapping[str, Any], name: str) -> str | None:
96
+ value = record.get(name)
97
+ if value is not None and not isinstance(value, str):
98
+ raise _config_error(name, "must be a string or null", value)
99
+ return value
100
+
101
+
102
+ def _str_list(record: Mapping[str, Any], name: str, label: str | None = None) -> list[str]:
103
+ value = record.get(name, [])
104
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
105
+ raise _config_error(label or name, "must be an array of strings", value)
106
+ return [str(item) for item in value]
107
+
108
+
109
+ def load_config(home: Path) -> Config:
110
+ path = home / CONFIG_FILE
111
+ try:
112
+ data = path.read_bytes()
113
+ except FileNotFoundError:
114
+ return Config()
115
+ except OSError as exc:
116
+ raise CliError("config_unreadable", f"cannot read {path}: {exc}", Exit.USAGE) from None
117
+ try:
118
+ record = parse_json_object(data, file=CONFIG_FILE)
119
+ except SciError as exc:
120
+ raise CliError(
121
+ "config_invalid", f"{path} is not a valid JSON object: {exc}", Exit.USAGE
122
+ ) from None
123
+ trust_record = record.get("timestamp_trust", {})
124
+ if not isinstance(trust_record, dict):
125
+ raise _config_error("timestamp_trust", "must be an object", trust_record)
126
+ freetsa = trust_record.get("freetsa", True)
127
+ if not isinstance(freetsa, bool):
128
+ raise _config_error("timestamp_trust.freetsa", "must be a Boolean", freetsa)
129
+ trust = TrustSettings(
130
+ freetsa=freetsa,
131
+ anchors=_str_list(trust_record, "anchors", "timestamp_trust.anchors"),
132
+ crls=_str_list(trust_record, "crls", "timestamp_trust.crls"),
133
+ crl_urls=_str_list(trust_record, "crl_urls", "timestamp_trust.crl_urls"),
134
+ )
135
+ config = Config(
136
+ index=_optional_str(record, "index"),
137
+ key=_optional_str(record, "key"),
138
+ folders=_str_list(record, "folders"),
139
+ tsa_url=_optional_str(record, "tsa_url"),
140
+ timestamp_trust=trust,
141
+ other={
142
+ k: v
143
+ for k, v in record.items()
144
+ if k not in ("index", "key", "folders", "tsa_url", "timestamp_trust")
145
+ },
146
+ )
147
+ if config.index is not None:
148
+ try:
149
+ config.index = normalize_origin(config.index)
150
+ except CliError:
151
+ raise _config_error("index", "must be an HTTPS origin", config.index) from None
152
+ if config.tsa_url is not None:
153
+ try:
154
+ config.tsa_url = normalize_url(config.tsa_url, what="TSA URL")
155
+ except CliError:
156
+ raise _config_error(
157
+ "tsa_url", "must be an absolute HTTPS URL", config.tsa_url
158
+ ) from None
159
+ return config
160
+
161
+
162
+ def save_config(home: Path, config: Config) -> None:
163
+ ensure_state_dir(home)
164
+ data = json.dumps(config.to_json(), indent=2, ensure_ascii=False).encode("utf-8") + b"\n"
165
+ atomic_write_bytes(home / CONFIG_FILE, data)
166
+
167
+
168
+ # --------------------------------------------------------------------------- URLs
169
+
170
+
171
+ def is_loopback(host: str) -> bool:
172
+ if host == "localhost":
173
+ return True
174
+ try:
175
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
176
+ except ValueError:
177
+ return False
178
+
179
+
180
+ def _split(value: str, what: str) -> urllib.parse.SplitResult:
181
+ try:
182
+ parts = urllib.parse.urlsplit(value)
183
+ _ = parts.port
184
+ except ValueError:
185
+ raise CliError("invalid_url", f"{what} is not a valid URL", observed=value) from None
186
+ if not parts.scheme or not parts.hostname:
187
+ raise CliError("invalid_url", f"{what} must be an absolute URL", observed=value)
188
+ scheme = parts.scheme.lower()
189
+ if scheme == "http" and not is_loopback(parts.hostname):
190
+ raise CliError(
191
+ "insecure_url",
192
+ f"{what} must use HTTPS (plain HTTP is accepted only for loopback test servers)",
193
+ observed=value,
194
+ )
195
+ if scheme not in ("http", "https"):
196
+ raise CliError("invalid_url", f"{what} must use HTTPS", observed=value)
197
+ if parts.username is not None or parts.password is not None:
198
+ raise CliError("invalid_url", f"{what} must not contain credentials", observed=value)
199
+ return parts
200
+
201
+
202
+ def normalize_origin(value: str, *, what: str = "index origin") -> str:
203
+ """Validate an HTTPS origin and return ``scheme://host[:port]``."""
204
+ if "://" not in value:
205
+ value = "https://" + value
206
+ parts = _split(value, what)
207
+ if parts.path not in ("", "/") or parts.query or parts.fragment:
208
+ raise CliError(
209
+ "invalid_origin",
210
+ f"{what} must be an origin without path, query, or fragment",
211
+ observed=value,
212
+ )
213
+ assert parts.hostname is not None
214
+ host = parts.hostname.lower()
215
+ if ":" in host:
216
+ host = f"[{host}]"
217
+ port = f":{parts.port}" if parts.port is not None else ""
218
+ return f"{parts.scheme.lower()}://{host}{port}"
219
+
220
+
221
+ def normalize_url(value: str, *, what: str = "URL") -> str:
222
+ """Validate an absolute HTTPS URL (fragment not allowed)."""
223
+ parts = _split(value, what)
224
+ if parts.fragment:
225
+ raise CliError("invalid_url", f"{what} must not contain a fragment", observed=value)
226
+ return value
227
+
228
+
229
+ def same_path(a: str | os.PathLike[str], b: str | os.PathLike[str]) -> bool:
230
+ try:
231
+ return os.path.samefile(a, b)
232
+ except OSError:
233
+ return Path(a).absolute() == Path(b).absolute()
pysci/_cli/context.py ADDED
@@ -0,0 +1,79 @@
1
+ """Per-invocation context: environment, state directory, configuration, I/O, and HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from . import __version__
14
+ from .clients.http import DEFAULT_TIMEOUT, Deadline, HttpClient, Pacer, Transport, UrllibTransport
15
+ from .config import Config, load_config, save_config, state_dir
16
+
17
+
18
+ def run_command(argv: Sequence[str], timeout: float) -> str | None:
19
+ """Run a helper command (e.g. ``gh auth token``); return stripped stdout or None."""
20
+ try:
21
+ completed = subprocess.run(
22
+ list(argv), capture_output=True, text=True, timeout=timeout, check=False
23
+ )
24
+ except (OSError, subprocess.SubprocessError):
25
+ return None
26
+ if completed.returncode != 0:
27
+ return None
28
+ return completed.stdout.strip() or None
29
+
30
+
31
+ @dataclass
32
+ class Context:
33
+ env: Mapping[str, str]
34
+ stdout: TextIO = field(default_factory=lambda: sys.stdout)
35
+ stderr: TextIO = field(default_factory=lambda: sys.stderr)
36
+ stdin: TextIO = field(default_factory=lambda: sys.stdin)
37
+ transport: Transport | None = None
38
+ sleep: Callable[[float], None] = time.sleep
39
+ runner: Callable[[Sequence[str], float], str | None] = run_command
40
+ cwd: Path = field(default_factory=Path.cwd)
41
+ _config: Config | None = None
42
+ _http: HttpClient | None = None
43
+
44
+ @property
45
+ def home(self) -> Path:
46
+ return state_dir(self.env)
47
+
48
+ @property
49
+ def config(self) -> Config:
50
+ if self._config is None:
51
+ self._config = load_config(self.home)
52
+ return self._config
53
+
54
+ def save_config(self) -> None:
55
+ save_config(self.home, self.config)
56
+
57
+ @property
58
+ def http(self) -> HttpClient:
59
+ if self._http is None:
60
+ self._http = HttpClient(
61
+ self.transport or UrllibTransport(),
62
+ user_agent=f"pysci/{__version__}",
63
+ pacer=Pacer(self.home / "pace", sleep=self.sleep),
64
+ sleep=self.sleep,
65
+ )
66
+ return self._http
67
+
68
+ def deadline(self, seconds: float | None) -> Deadline:
69
+ return Deadline(DEFAULT_TIMEOUT if seconds is None else seconds)
70
+
71
+ def interactive(self) -> bool:
72
+ try:
73
+ return bool(self.stdin.isatty())
74
+ except (AttributeError, ValueError):
75
+ return False
76
+
77
+ def resolve(self, path: str | Path) -> Path:
78
+ candidate = Path(path).expanduser()
79
+ return candidate if candidate.is_absolute() else self.cwd / candidate
@@ -0,0 +1,163 @@
1
+ """Publishing destinations: local folders, ``@local``, GitHub, and Hugging Face datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .config import normalize_url
10
+ from .errors import CliError, Exit
11
+
12
+ _NAME = r"[A-Za-z0-9][A-Za-z0-9._-]*"
13
+ _GH_OWNER = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})"
14
+ _SUFFIX = r"(?:\.git)?/?"
15
+
16
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
17
+ (
18
+ "github",
19
+ re.compile(rf"https://github\.com/(?P<owner>{_GH_OWNER})/(?P<repo>{_NAME}?){_SUFFIX}"),
20
+ ),
21
+ ("github", re.compile(rf"git@github\.com:(?P<owner>{_GH_OWNER})/(?P<repo>{_NAME}?){_SUFFIX}")),
22
+ (
23
+ "github",
24
+ re.compile(rf"ssh://git@github\.com/(?P<owner>{_GH_OWNER})/(?P<repo>{_NAME}?){_SUFFIX}"),
25
+ ),
26
+ (
27
+ "huggingface",
28
+ re.compile(
29
+ rf"https://huggingface\.co/datasets/(?P<owner>{_NAME})/(?P<repo>{_NAME}?){_SUFFIX}"
30
+ ),
31
+ ),
32
+ (
33
+ "huggingface",
34
+ re.compile(
35
+ rf"git@(?:hf\.co|huggingface\.co):datasets/(?P<owner>{_NAME})/(?P<repo>{_NAME}?)"
36
+ rf"{_SUFFIX}"
37
+ ),
38
+ ),
39
+ (
40
+ "huggingface",
41
+ re.compile(
42
+ rf"ssh://git@(?:hf\.co|huggingface\.co)/datasets/(?P<owner>{_NAME})/"
43
+ rf"(?P<repo>{_NAME}?){_SUFFIX}"
44
+ ),
45
+ ),
46
+ ]
47
+
48
+ _REMOTE_HINT = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://|^[^/\s]+@[^/\s]+:")
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class Destination:
53
+ kind: str
54
+ """``folder``, ``github``, or ``huggingface``."""
55
+ key: str
56
+ display: str
57
+ path: Path | None = None
58
+ owner: str | None = None
59
+ repo: str | None = None
60
+
61
+ @property
62
+ def repository(self) -> str:
63
+ return f"{self.owner}/{self.repo}"
64
+
65
+
66
+ def parse_remote(value: str) -> Destination | None:
67
+ """Recognize a GitHub or Hugging Face repository address; None if not remote-looking.
68
+
69
+ Raises:
70
+ CliError: the value looks like a remote address but is unsupported.
71
+ """
72
+ if not _REMOTE_HINT.search(value):
73
+ return None
74
+ if "?" in value or "#" in value:
75
+ raise CliError(
76
+ "unsupported_destination",
77
+ f"{value}: repository addresses must not contain a query or fragment",
78
+ Exit.USAGE,
79
+ )
80
+ for kind, pattern in _PATTERNS:
81
+ match = pattern.fullmatch(value)
82
+ if match is None:
83
+ continue
84
+ owner, repo = match.group("owner"), match.group("repo")
85
+ if repo.endswith(".git"):
86
+ repo = repo[: -len(".git")]
87
+ if not repo or repo in (".", "..") or repo.endswith("."):
88
+ break
89
+ if kind == "github":
90
+ return Destination(
91
+ "github",
92
+ f"github:{owner.lower()}/{repo.lower()}",
93
+ f"https://github.com/{owner}/{repo}",
94
+ owner=owner,
95
+ repo=repo,
96
+ )
97
+ return Destination(
98
+ "huggingface",
99
+ f"huggingface:{owner.lower()}/{repo.lower()}",
100
+ f"https://huggingface.co/datasets/{owner}/{repo}",
101
+ owner=owner,
102
+ repo=repo,
103
+ )
104
+ raise CliError(
105
+ "unsupported_destination",
106
+ f"{value} is not a supported destination; use a folder, @local, "
107
+ "https://github.com/OWNER/REPO, or https://huggingface.co/datasets/OWNER/REPO "
108
+ "(file and branch URLs are not accepted)",
109
+ Exit.USAGE,
110
+ )
111
+
112
+
113
+ def resolve_destinations(
114
+ values: list[str], known_folders: list[str], cwd: Path
115
+ ) -> list[Destination]:
116
+ destinations: list[Destination] = []
117
+ seen: set[str] = set()
118
+
119
+ def add(destination: Destination) -> None:
120
+ if destination.key not in seen:
121
+ seen.add(destination.key)
122
+ destinations.append(destination)
123
+
124
+ for value in values:
125
+ if value == "@local":
126
+ for folder in known_folders:
127
+ path = Path(folder)
128
+ add(Destination("folder", f"folder:{path}", str(path), path=path))
129
+ continue
130
+ if value.startswith("@"):
131
+ raise CliError("unsupported_destination", f"unknown destination {value!r}", Exit.USAGE)
132
+ remote = parse_remote(value)
133
+ if remote is not None:
134
+ add(remote)
135
+ continue
136
+ path = Path(value).expanduser()
137
+ path = path if path.is_absolute() else cwd / path
138
+ canonical = path.resolve() if path.exists() else path.absolute()
139
+ add(Destination("folder", f"folder:{canonical}", str(canonical), path=canonical))
140
+ if not destinations:
141
+ raise CliError(
142
+ "no_destinations", "no destinations selected (no known folders?)", Exit.USAGE
143
+ )
144
+ return destinations
145
+
146
+
147
+ def resolve_archive(value: str) -> str:
148
+ """Expand supported repository addresses; preserve explicit descriptor URLs.
149
+
150
+ Publishing layouts are deterministic, so this needs neither credentials nor
151
+ local upload history. A repository address does not prove files are hosted.
152
+ """
153
+ try:
154
+ remote = parse_remote(value)
155
+ except CliError:
156
+ # Other HTTPS paths may be explicit descriptors (including custom layouts
157
+ # on a supported host); validate them as URLs without rewriting them.
158
+ remote = None
159
+ if remote is not None:
160
+ if remote.kind == "github":
161
+ return f"https://raw.githubusercontent.com/{remote.repository}/main/sci/archive.json"
162
+ return f"https://huggingface.co/datasets/{remote.repository}/resolve/main/sci/archive.json"
163
+ return normalize_url(value, what="archive descriptor URL or supported repository address")
pysci/_cli/errors.py ADDED
@@ -0,0 +1,168 @@
1
+ """CLI errors, exit codes, and conversion of protocol issues into error records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass
7
+ from dataclasses import field as dc_field
8
+ from enum import IntEnum
9
+ from typing import Any
10
+
11
+ from pysci.errors import Issue, Outcome, SciError
12
+
13
+
14
+ class Exit(IntEnum):
15
+ """Process exit codes (cli/DESIGN.md, Results)."""
16
+
17
+ OK = 0
18
+ USAGE = 2
19
+ """Invalid command or missing configuration."""
20
+ INVALID = 3
21
+ """Failed protocol, signature, or content check."""
22
+ UNSUPPORTED = 4
23
+ """Unsupported schema or service capability."""
24
+ INCOMPLETE = 5
25
+ """Incomplete operation, temporary failure, or partial result."""
26
+ REFUSED = 6
27
+ """Baseline refusal before signing or service policy refusal."""
28
+
29
+
30
+ EXIT_MEANINGS: dict[int, str] = {
31
+ Exit.OK: "requested operation completed",
32
+ Exit.USAGE: "invalid command or missing configuration",
33
+ Exit.INVALID: "failed protocol, signature, or content check",
34
+ Exit.UNSUPPORTED: "unsupported schema or service capability",
35
+ Exit.INCOMPLETE: "incomplete operation, temporary failure, or partial result",
36
+ Exit.REFUSED: "baseline refusal before signing or service policy refusal",
37
+ }
38
+
39
+ _OUTCOME_EXIT: dict[Outcome, Exit] = {
40
+ Outcome.VALID: Exit.OK,
41
+ Outcome.INVALID: Exit.INVALID,
42
+ Outcome.UNSUPPORTED: Exit.UNSUPPORTED,
43
+ Outcome.INCOMPLETE: Exit.INCOMPLETE,
44
+ }
45
+
46
+ # Severity order used to combine several exit codes into one.
47
+ _SEVERITY = [Exit.OK, Exit.INCOMPLETE, Exit.REFUSED, Exit.UNSUPPORTED, Exit.INVALID, Exit.USAGE]
48
+
49
+
50
+ def exit_for_outcome(outcome: Outcome) -> Exit:
51
+ return _OUTCOME_EXIT[outcome]
52
+
53
+
54
+ def combine_exits(codes: Iterable[int]) -> Exit:
55
+ """The most severe exit code; a mix of successes and failures is partial (5)."""
56
+ values = [Exit(code) for code in codes]
57
+ if not values:
58
+ return Exit.OK
59
+ failures = [code for code in values if code is not Exit.OK]
60
+ if not failures:
61
+ return Exit.OK
62
+ if len(failures) < len(values):
63
+ return Exit.INCOMPLETE
64
+ return max(failures, key=_SEVERITY.index)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class ErrorRecord:
69
+ """One reported error: code, explanation, retryability, and validation location."""
70
+
71
+ code: str
72
+ message: str
73
+ retryable: bool = False
74
+ file: str | None = None
75
+ field: str | None = None
76
+ requirement: str | None = None
77
+ observed: str | None = None
78
+ section: str | None = None
79
+ extra: dict[str, Any] = dc_field(default_factory=dict)
80
+
81
+ def to_json(self) -> dict[str, Any]:
82
+ out: dict[str, Any] = {
83
+ "code": self.code,
84
+ "message": self.message,
85
+ "retryable": self.retryable,
86
+ }
87
+ for name in ("file", "field", "requirement", "observed", "section"):
88
+ value = getattr(self, name)
89
+ if value is not None:
90
+ out[name] = value
91
+ out.update(self.extra)
92
+ return out
93
+
94
+ def __str__(self) -> str:
95
+ where = self.file or ""
96
+ if self.field:
97
+ where += ("#" if where else "") + self.field
98
+ text = f"[{self.code}] "
99
+ if where:
100
+ text += where + ": "
101
+ text += self.message
102
+ if self.section:
103
+ text += f" (§{self.section})"
104
+ if self.observed is not None:
105
+ text += f"; observed {self.observed}"
106
+ if self.retryable:
107
+ text += " (retrying may help)"
108
+ return text
109
+
110
+
111
+ def issue_record(issue: Issue) -> ErrorRecord:
112
+ return ErrorRecord(
113
+ code=issue.code,
114
+ message=issue.requirement,
115
+ retryable=issue.outcome is Outcome.INCOMPLETE,
116
+ file=issue.file,
117
+ field=issue.field,
118
+ requirement=issue.requirement,
119
+ observed=issue.observed,
120
+ section=issue.section,
121
+ )
122
+
123
+
124
+ def issue_json(issue: Issue) -> dict[str, Any]:
125
+ return issue_record(issue).to_json()
126
+
127
+
128
+ class CliError(Exception):
129
+ """A failure reported to the user with an exit code and error records."""
130
+
131
+ def __init__(
132
+ self,
133
+ code: str,
134
+ message: str,
135
+ exit_code: int = Exit.USAGE,
136
+ *,
137
+ retryable: bool = False,
138
+ records: Sequence[ErrorRecord] = (),
139
+ result: dict[str, Any] | None = None,
140
+ **extra: Any,
141
+ ) -> None:
142
+ super().__init__(message)
143
+ self.exit_code = Exit(exit_code)
144
+ head = ErrorRecord(code, message, retryable, extra=dict(extra.items()))
145
+ self.records: list[ErrorRecord] = [head, *records]
146
+ self.result = result or {}
147
+
148
+ @property
149
+ def code(self) -> str:
150
+ return self.records[0].code
151
+
152
+ @classmethod
153
+ def from_sci(cls, exc: SciError, message: str | None = None) -> CliError:
154
+ records = [issue_record(issue) for issue in exc.issues]
155
+ exit_code = exit_for_outcome(exc.outcome)
156
+ first = records[0]
157
+ error = cls(
158
+ first.code,
159
+ message or first.message,
160
+ exit_code,
161
+ retryable=exc.outcome is Outcome.INCOMPLETE,
162
+ )
163
+ error.records = records if message is None else [error.records[0], *records]
164
+ return error
165
+
166
+
167
+ def usage(code: str, message: str, **extra: Any) -> CliError:
168
+ return CliError(code, message, Exit.USAGE, **extra)