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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- 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)
|