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/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()]
@@ -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