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/main.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""The ``sci`` command: argument parsing, dispatch, and error reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from pysci import PROTOCOL_VERSION
|
|
14
|
+
from pysci import __version__ as pysci_version
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .clients.http import Transport
|
|
18
|
+
from .context import Context, run_command
|
|
19
|
+
from .errors import EXIT_MEANINGS, CliError, Exit
|
|
20
|
+
from .output import Result, render
|
|
21
|
+
from .spec import add_command
|
|
22
|
+
|
|
23
|
+
DRAFT_GUIDE = """\
|
|
24
|
+
write a publication (complete rules: `sci spec --section 2` and `--section 4`):
|
|
25
|
+
draft/
|
|
26
|
+
sci.json required metadata, max 64 KiB
|
|
27
|
+
README.md required main text, nonblank UTF-8 Markdown, max 1 MiB
|
|
28
|
+
LICENSE written by `sci license` (CC BY 4.0 text/figures/data, MIT code)
|
|
29
|
+
files/ optional code, data, figures; max 1,000 files, ZIP max 64 MiB
|
|
30
|
+
sci.json example:
|
|
31
|
+
{
|
|
32
|
+
"title": "Spike timing predicts choice in mouse V1 across three datasets",
|
|
33
|
+
"abstract": "Question, approach, main finding, and main limitation.",
|
|
34
|
+
"categories": [{"scheme": "arxiv", "code": "q-bio.NC"}],
|
|
35
|
+
"keywords": ["spike timing", "decision making"],
|
|
36
|
+
"display": [{"path": "files/accuracy.png", "caption": "Accuracy by time bin."}],
|
|
37
|
+
"citations": [
|
|
38
|
+
{"target": "sci:sha256:<64 hex digits>", "relations": ["uses"],
|
|
39
|
+
"reason": "We reuse its preprocessing code."},
|
|
40
|
+
{"target": "doi:10.48550/arXiv.1706.03762"},
|
|
41
|
+
{"target": "https://example.org/data/release-3"}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
Required: title (max 200 characters) and abstract (max 2,500). Optional:
|
|
45
|
+
categories (max 3 {scheme, code}; arxiv codes such as cs.LG, q-bio.NC),
|
|
46
|
+
keywords (max 20, each max 64 characters), display (max 3 paths under
|
|
47
|
+
files/ shown first; caption max 2,000), and citations (max 1,000). Display
|
|
48
|
+
files must be static .png images: max 2 MiB, 8,000,000 pixels, and 8,192
|
|
49
|
+
pixels per side (other formats can stay in files/ but not in display).
|
|
50
|
+
Omit unused fields; null and other fields are rejected.
|
|
51
|
+
Line breaks and tabs are allowed only in abstract, citation reason, README.md,
|
|
52
|
+
and LICENSE. File paths use only ASCII letters, digits, ".", "_", and "-"
|
|
53
|
+
per component (no spaces), max 255 bytes.
|
|
54
|
+
Citations: target is "sci:sha256:<digest>" (the ID printed by `sci sign`),
|
|
55
|
+
"doi:<DOI>" (no https://doi.org/ prefix), or an http(s) URL. Optional
|
|
56
|
+
relations: uses, reviews, supports, challenges, supersedes; optional reason
|
|
57
|
+
(max 1,000). Citations are signed: add them before signing.
|
|
58
|
+
`sci sign` adds manifest.json and manifest.json.sig to the draft. Changing
|
|
59
|
+
any file afterwards needs `sci sign --replace`, which creates a new ID. To
|
|
60
|
+
correct a published result, sign a new publication that cites the old one
|
|
61
|
+
with "relations": ["supersedes"].
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DESCRIPTION = f"""\
|
|
65
|
+
sci: sign, verify, publish, and find SCI research publications (protocol {PROTOCOL_VERSION}).
|
|
66
|
+
|
|
67
|
+
A publication is a signed folder or <digest>.zip with README.md, sci.json
|
|
68
|
+
(metadata and citations), an optional LICENSE, and files/. Its ID is the
|
|
69
|
+
SHA-256 of manifest.json: sha256:<digest>. Everything verifies offline.
|
|
70
|
+
|
|
71
|
+
local campaign (offline):
|
|
72
|
+
sci keygen create ~/.sci/key.pem (owner-only)
|
|
73
|
+
sci license ./draft select the SCI baseline license
|
|
74
|
+
sci check ./draft validate and size the draft
|
|
75
|
+
sci sign ./draft --no-timestamp --output ./campaign
|
|
76
|
+
sci verify ./campaign/<digest>.zip complete bundle checks
|
|
77
|
+
sci search "neural decoding" --in ./campaign
|
|
78
|
+
sci search --in ./campaign --limit 50 list publications, then --cursor TOKEN
|
|
79
|
+
sci read ./campaign/<digest>.zip verified metadata and README
|
|
80
|
+
sci read ./campaign/<digest>.zip --file files/data.csv --output data.csv
|
|
81
|
+
reuse one verified file
|
|
82
|
+
sci publish ./campaign --to ./mirror copy verified ZIPs to a folder
|
|
83
|
+
|
|
84
|
+
remote publishing:
|
|
85
|
+
sci sign ./draft --output ./campaign sign + FreeTSA timestamp
|
|
86
|
+
sci publish ./campaign --to https://github.com/lab/research --create
|
|
87
|
+
sci index https://sciscape.io choose an index (no default)
|
|
88
|
+
sci submit ./campaign --archive \\
|
|
89
|
+
https://github.com/lab/research
|
|
90
|
+
sci search "neural decoding" --in @index catalog metadata, newest first
|
|
91
|
+
sci pull sha256:<digest> download and verify completely
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
EPILOG = (
|
|
95
|
+
"Run `sci COMMAND --help` for arguments, outputs, file changes, network use, exit codes,\n"
|
|
96
|
+
"and an example. Add --json to any command for a machine-readable result object.\n"
|
|
97
|
+
"State lives in ~/.sci (override with SCI_HOME): config.json, key.pem, catalog.sqlite.\n"
|
|
98
|
+
"Jobs on one machine can share it; keep it on a local disk, not a network mount.\n\n"
|
|
99
|
+
"exit codes:\n" + "\n".join(f" {code} {text}" for code, text in EXIT_MEANINGS.items())
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _Parser(argparse.ArgumentParser):
|
|
104
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
105
|
+
raise _UsageError(self, message)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _UsageError(Exception):
|
|
109
|
+
def __init__(self, parser: argparse.ArgumentParser, message: str) -> None:
|
|
110
|
+
super().__init__(message)
|
|
111
|
+
self.parser = parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _deliver(write: Callable[[], object], out: TextIO, err: TextIO) -> None:
|
|
115
|
+
"""Write output. A reader that has gone away (`sci ... | head`) does not change the result."""
|
|
116
|
+
try:
|
|
117
|
+
write()
|
|
118
|
+
out.flush()
|
|
119
|
+
err.flush()
|
|
120
|
+
except BrokenPipeError:
|
|
121
|
+
# Point the closed streams at /dev/null so interpreter shutdown cannot fail on them.
|
|
122
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
123
|
+
for stream in (out, err):
|
|
124
|
+
with contextlib.suppress(OSError, ValueError):
|
|
125
|
+
os.dup2(devnull, stream.fileno())
|
|
126
|
+
os.close(devnull)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
130
|
+
from .commands import COMMANDS
|
|
131
|
+
|
|
132
|
+
parser = _Parser(
|
|
133
|
+
prog="sci",
|
|
134
|
+
description=DESCRIPTION + "\n" + DRAFT_GUIDE,
|
|
135
|
+
epilog=EPILOG,
|
|
136
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--version",
|
|
140
|
+
action="version",
|
|
141
|
+
version=f"sci {__version__} (pysci {pysci_version}, SCI protocol {PROTOCOL_VERSION})",
|
|
142
|
+
)
|
|
143
|
+
subparsers = parser.add_subparsers(
|
|
144
|
+
dest="command", metavar="COMMAND", title="commands", parser_class=_Parser
|
|
145
|
+
)
|
|
146
|
+
for command in COMMANDS:
|
|
147
|
+
add_command(subparsers, command)
|
|
148
|
+
return parser
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(
|
|
152
|
+
argv: Sequence[str] | None = None,
|
|
153
|
+
*,
|
|
154
|
+
env: Mapping[str, str] | None = None,
|
|
155
|
+
transport: Transport | None = None,
|
|
156
|
+
stdout: TextIO | None = None,
|
|
157
|
+
stderr: TextIO | None = None,
|
|
158
|
+
stdin: TextIO | None = None,
|
|
159
|
+
sleep: Callable[[float], None] | None = None,
|
|
160
|
+
runner: Callable[[Sequence[str], float], str | None] | None = None,
|
|
161
|
+
cwd: Path | None = None,
|
|
162
|
+
) -> int:
|
|
163
|
+
"""Run the CLI; return the exit code. Keyword arguments allow embedding and tests."""
|
|
164
|
+
out = stdout or sys.stdout
|
|
165
|
+
err = stderr or sys.stderr
|
|
166
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
167
|
+
parser = build_parser()
|
|
168
|
+
wants_json = "--json" in args_list
|
|
169
|
+
try:
|
|
170
|
+
parsed: list[argparse.Namespace] = []
|
|
171
|
+
|
|
172
|
+
def parse() -> None:
|
|
173
|
+
with contextlib.redirect_stdout(out):
|
|
174
|
+
parsed.append(parser.parse_args(args_list))
|
|
175
|
+
|
|
176
|
+
_deliver(parse, out, err)
|
|
177
|
+
if not parsed: # --help or --version, and the reader closed the pipe
|
|
178
|
+
return 0
|
|
179
|
+
args = parsed[0]
|
|
180
|
+
except _UsageError as exc:
|
|
181
|
+
command = getattr(exc.parser, "prog", "sci").removeprefix("sci").strip() or "sci"
|
|
182
|
+
if wants_json:
|
|
183
|
+
result = Result(command, exit_code=Exit.USAGE)
|
|
184
|
+
result.errors.append(CliError("usage", str(exc)).records[0])
|
|
185
|
+
_deliver(lambda: render(result, as_json=True, stdout=out, stderr=err), out, err)
|
|
186
|
+
else:
|
|
187
|
+
usage_parser = exc.parser
|
|
188
|
+
message = f"{usage_parser.prog}: error: {exc}\n"
|
|
189
|
+
|
|
190
|
+
def report_usage() -> None:
|
|
191
|
+
usage_parser.print_usage(err)
|
|
192
|
+
err.write(message)
|
|
193
|
+
|
|
194
|
+
_deliver(report_usage, out, err)
|
|
195
|
+
return int(Exit.USAGE)
|
|
196
|
+
except SystemExit as exc: # --help and --version
|
|
197
|
+
_deliver(lambda: None, out, err)
|
|
198
|
+
return int(exc.code or 0) if isinstance(exc.code, int) else 0
|
|
199
|
+
if args.command is None:
|
|
200
|
+
_deliver(lambda: parser.print_help(out), out, err)
|
|
201
|
+
return int(Exit.USAGE)
|
|
202
|
+
import time
|
|
203
|
+
|
|
204
|
+
ctx = Context(
|
|
205
|
+
env=dict(os.environ if env is None else env),
|
|
206
|
+
stdout=out,
|
|
207
|
+
stderr=err,
|
|
208
|
+
stdin=stdin or sys.stdin,
|
|
209
|
+
transport=transport,
|
|
210
|
+
sleep=sleep or time.sleep,
|
|
211
|
+
runner=runner or run_command,
|
|
212
|
+
cwd=cwd or Path.cwd(),
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
result = args.handler(args, ctx)
|
|
216
|
+
except CliError as exc:
|
|
217
|
+
result = Result(args.command_name, exit_code=exc.exit_code, data=exc.result)
|
|
218
|
+
result.errors.extend(exc.records)
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
result = Result(args.command_name, exit_code=Exit.INCOMPLETE)
|
|
221
|
+
result.errors.append(
|
|
222
|
+
CliError("interrupted", "interrupted; completed items were kept").records[0]
|
|
223
|
+
)
|
|
224
|
+
_deliver(lambda: render(result, as_json=args.json, stdout=out, stderr=err), out, err)
|
|
225
|
+
return int(result.exit_code)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__": # pragma: no cover
|
|
229
|
+
sys.exit(main())
|
pysci/_cli/output.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Result objects and their rendering as human text or ``--json`` machine output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
from .errors import EXIT_MEANINGS, ErrorRecord, Exit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class Result:
|
|
14
|
+
"""The outcome of one command.
|
|
15
|
+
|
|
16
|
+
``data`` is the machine-readable result object; ``lines`` is the human
|
|
17
|
+
text. Errors and warnings are rendered for both audiences.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
command: str
|
|
21
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
exit_code: int = Exit.OK
|
|
23
|
+
errors: list[ErrorRecord] = field(default_factory=list)
|
|
24
|
+
warnings: list[str] = field(default_factory=list)
|
|
25
|
+
lines: list[str] = field(default_factory=list)
|
|
26
|
+
partial: bool = False
|
|
27
|
+
|
|
28
|
+
def say(self, line: str = "") -> None:
|
|
29
|
+
self.lines.append(line)
|
|
30
|
+
|
|
31
|
+
def warn(self, message: str) -> None:
|
|
32
|
+
self.warnings.append(message)
|
|
33
|
+
|
|
34
|
+
def error(self, record: ErrorRecord) -> None:
|
|
35
|
+
self.errors.append(record)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def status(self) -> str:
|
|
39
|
+
if self.exit_code == Exit.OK:
|
|
40
|
+
return "ok"
|
|
41
|
+
if self.partial:
|
|
42
|
+
return "partial"
|
|
43
|
+
return "failed"
|
|
44
|
+
|
|
45
|
+
def to_json(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"command": self.command,
|
|
48
|
+
"status": self.status,
|
|
49
|
+
"exit_code": int(self.exit_code),
|
|
50
|
+
"result": self.data,
|
|
51
|
+
"errors": [record.to_json() for record in self.errors],
|
|
52
|
+
"warnings": list(self.warnings),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render(result: Result, *, as_json: bool, stdout: TextIO, stderr: TextIO) -> None:
|
|
57
|
+
if as_json:
|
|
58
|
+
stdout.write(json.dumps(result.to_json(), indent=2, ensure_ascii=False, sort_keys=False))
|
|
59
|
+
stdout.write("\n")
|
|
60
|
+
return
|
|
61
|
+
for line in result.lines:
|
|
62
|
+
stdout.write(line + "\n")
|
|
63
|
+
for warning in result.warnings:
|
|
64
|
+
stderr.write(f"warning: {warning}\n")
|
|
65
|
+
# Commands such as `check` list their issues in the report itself; do not
|
|
66
|
+
# print the same problem a second time on stderr.
|
|
67
|
+
shown = {line.strip() for line in result.lines}
|
|
68
|
+
for record in result.errors:
|
|
69
|
+
text = str(record)
|
|
70
|
+
if text in shown or text.removesuffix(" (retrying may help)") in shown:
|
|
71
|
+
continue
|
|
72
|
+
stderr.write(f"error: {text}\n")
|
|
73
|
+
if result.exit_code != Exit.OK:
|
|
74
|
+
meaning = EXIT_MEANINGS.get(int(result.exit_code), "")
|
|
75
|
+
label = "partial result" if result.partial else "failed"
|
|
76
|
+
stderr.write(f"sci {result.command}: {label} (exit {int(result.exit_code)}: {meaning})\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def indent(text: str, prefix: str = " ") -> list[str]:
|
|
80
|
+
return [prefix + line if line else line for line in text.splitlines()]
|
pysci/_cli/retrieval.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Resolving publication sources by ID: index lookup (§8.1.1) or explicit archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .clients.http import Deadline
|
|
8
|
+
from .clients.index import IndexClient
|
|
9
|
+
from .commands.common import archive_urls, index_origin
|
|
10
|
+
from .context import Context
|
|
11
|
+
from .errors import CliError, Exit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Sources:
|
|
16
|
+
descriptors: list[str]
|
|
17
|
+
index: IndexClient | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_sources(
|
|
21
|
+
ctx: Context,
|
|
22
|
+
digest: str,
|
|
23
|
+
*,
|
|
24
|
+
archives: list[str] | None,
|
|
25
|
+
index_url: str | None,
|
|
26
|
+
deadline: Deadline,
|
|
27
|
+
) -> Sources:
|
|
28
|
+
"""Archive descriptor URLs for ``digest``.
|
|
29
|
+
|
|
30
|
+
Explicit ``--archive`` URLs need no index. Otherwise the configured index
|
|
31
|
+
or ``--in URL`` is asked; ``404`` or an empty list is ``no_known_sources``.
|
|
32
|
+
"""
|
|
33
|
+
explicit = archive_urls(archives)
|
|
34
|
+
if explicit and index_url:
|
|
35
|
+
raise CliError("conflicting_options", "use either --archive or --in, not both")
|
|
36
|
+
if explicit:
|
|
37
|
+
return Sources(explicit, None)
|
|
38
|
+
client = IndexClient(ctx.http, index_origin(ctx, index_url, option="--in"))
|
|
39
|
+
descriptors = client.lookup(digest, deadline)
|
|
40
|
+
unique: list[str] = []
|
|
41
|
+
for url in descriptors:
|
|
42
|
+
if url not in unique:
|
|
43
|
+
unique.append(url)
|
|
44
|
+
if not unique:
|
|
45
|
+
raise CliError(
|
|
46
|
+
"no_known_sources",
|
|
47
|
+
f"index {client.origin} knows no sources for sha256:{digest}",
|
|
48
|
+
Exit.INCOMPLETE,
|
|
49
|
+
retryable=True,
|
|
50
|
+
)
|
|
51
|
+
return Sources(unique, client)
|
pysci/_cli/spec.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Command definitions from which the argument parser and the help text are generated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from .errors import EXIT_MEANINGS
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .context import Context
|
|
14
|
+
from .output import Result
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Arg:
|
|
19
|
+
flags: tuple[str, ...]
|
|
20
|
+
help: str
|
|
21
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def positional(self) -> bool:
|
|
25
|
+
return not self.flags[0].startswith("-")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def arg(*flags: str, help: str, **kwargs: Any) -> Arg:
|
|
29
|
+
return Arg(tuple(flags), help, kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TIMEOUT_ARG = arg(
|
|
33
|
+
"--timeout",
|
|
34
|
+
type=float,
|
|
35
|
+
metavar="SECONDS",
|
|
36
|
+
default=120.0,
|
|
37
|
+
help="operation deadline including retries; for batches, per publication and destination",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Command:
|
|
43
|
+
"""One subcommand: arguments, documentation sections, and its handler."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
usage_hint: str
|
|
47
|
+
summary: str
|
|
48
|
+
description: str
|
|
49
|
+
args: tuple[Arg, ...]
|
|
50
|
+
outputs: str
|
|
51
|
+
files: str
|
|
52
|
+
network: str
|
|
53
|
+
exit_codes: dict[int, str]
|
|
54
|
+
examples: tuple[str, ...]
|
|
55
|
+
run: Callable[[argparse.Namespace, Context], Result]
|
|
56
|
+
timeout: bool = False
|
|
57
|
+
|
|
58
|
+
def epilog(self) -> str:
|
|
59
|
+
lines = ["output:"]
|
|
60
|
+
lines += [f" {line}" for line in self.outputs.strip().splitlines()]
|
|
61
|
+
lines += [" --json prints one result object (command, status, exit_code, result,"]
|
|
62
|
+
lines += [" errors with code/message/retryable, warnings) instead of text."]
|
|
63
|
+
lines += ["", "files changed:"]
|
|
64
|
+
lines += [f" {line}" for line in self.files.strip().splitlines()]
|
|
65
|
+
lines += ["", "network:"]
|
|
66
|
+
lines += [f" {line}" for line in self.network.strip().splitlines()]
|
|
67
|
+
lines += ["", "exit codes:"]
|
|
68
|
+
codes = {0: EXIT_MEANINGS[0], 2: EXIT_MEANINGS[2], **self.exit_codes}
|
|
69
|
+
for code in sorted(codes):
|
|
70
|
+
lines.append(f" {code} {codes[code]}")
|
|
71
|
+
lines += ["", "example:" if len(self.examples) == 1 else "examples:"]
|
|
72
|
+
lines += [f" {example}" for example in self.examples]
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _help_text(spec: Arg) -> str:
|
|
77
|
+
text = spec.help
|
|
78
|
+
default = spec.kwargs.get("default")
|
|
79
|
+
if (
|
|
80
|
+
default not in (None, False, argparse.SUPPRESS, [])
|
|
81
|
+
and "default" not in text
|
|
82
|
+
and spec.kwargs.get("action") not in ("store_true", "store_false")
|
|
83
|
+
):
|
|
84
|
+
text += " (default: %(default)s)"
|
|
85
|
+
return text.replace("%", "%%").replace("%%(default)s", "%(default)s")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Formatter(argparse.RawDescriptionHelpFormatter):
|
|
89
|
+
def __init__(self, prog: str) -> None:
|
|
90
|
+
super().__init__(prog, max_help_position=30, width=100)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def add_command(subparsers: Any, command: Command) -> argparse.ArgumentParser:
|
|
94
|
+
parser: argparse.ArgumentParser = subparsers.add_parser(
|
|
95
|
+
command.name,
|
|
96
|
+
help=command.summary,
|
|
97
|
+
description=f"{command.summary}\n\n{command.description.strip()}",
|
|
98
|
+
epilog=command.epilog(),
|
|
99
|
+
formatter_class=_Formatter,
|
|
100
|
+
usage=f"sci {command.usage_hint}" if command.usage_hint else None,
|
|
101
|
+
)
|
|
102
|
+
positional = [a for a in command.args if a.positional]
|
|
103
|
+
optional = [a for a in command.args if not a.positional]
|
|
104
|
+
for spec in positional:
|
|
105
|
+
parser.add_argument(*spec.flags, help=_help_text(spec), **spec.kwargs)
|
|
106
|
+
for spec in optional:
|
|
107
|
+
parser.add_argument(*spec.flags, help=_help_text(spec), **spec.kwargs)
|
|
108
|
+
common = parser.add_argument_group("common options")
|
|
109
|
+
common.add_argument("--json", action="store_true", help="print a machine-readable JSON result")
|
|
110
|
+
if command.timeout:
|
|
111
|
+
common.add_argument(*TIMEOUT_ARG.flags, help=_help_text(TIMEOUT_ARG), **TIMEOUT_ARG.kwargs)
|
|
112
|
+
parser.set_defaults(handler=command.run, command_name=command.name)
|
|
113
|
+
return parser
|
|
File without changes
|