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/text.py ADDED
@@ -0,0 +1,226 @@
1
+ """Text character rules (protocol §4.2) using RFC 9839 Unicode Assignables.
2
+
3
+ Membership is decided by the fixed code-point intervals of RFC 9839 §4.3; no
4
+ runtime Unicode category data is consulted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import codecs
10
+ import re
11
+ from collections.abc import Iterable
12
+ from typing import Final
13
+
14
+ from .errors import Invalid, Issue, IssueCollector
15
+
16
+ __all__ = [
17
+ "ASSIGNABLE_RANGES",
18
+ "LINE_CONTROLS",
19
+ "TextFileValidator",
20
+ "check_text",
21
+ "check_text_file",
22
+ "decode_text_file",
23
+ "first_disallowed",
24
+ "is_assignable",
25
+ "is_nonblank",
26
+ ]
27
+
28
+ ASSIGNABLE_RANGES: Final[tuple[tuple[int, int], ...]] = (
29
+ (0x09, 0x0A),
30
+ (0x0D, 0x0D),
31
+ (0x20, 0x7E),
32
+ (0xA0, 0xD7FF),
33
+ (0xE000, 0xFDCF),
34
+ (0xFDF0, 0xFFFD),
35
+ *((plane << 16, (plane << 16) | 0xFFFD) for plane in range(1, 17)),
36
+ )
37
+ """Inclusive code-point intervals of RFC 9839 §4.3 Unicode Assignables."""
38
+
39
+ LINE_CONTROLS: Final = frozenset("\t\n\r")
40
+ """TAB, LF, CR: permitted only in multiline fields and text files."""
41
+
42
+ _BLANK: Final = frozenset(" \t\n\r")
43
+
44
+
45
+ def _char_class(ranges: Iterable[tuple[int, int]]) -> str:
46
+ parts = []
47
+ for low, high in ranges:
48
+ if low == high:
49
+ parts.append(re.escape(chr(low)))
50
+ else:
51
+ parts.append(f"{re.escape(chr(low))}-{re.escape(chr(high))}")
52
+ return "".join(parts)
53
+
54
+
55
+ _SINGLE_LINE_RANGES: Final = tuple(r for r in ASSIGNABLE_RANGES if r[0] > 0x0D)
56
+ _DISALLOWED_MULTILINE: Final = re.compile(f"[^{_char_class(ASSIGNABLE_RANGES)}]")
57
+ _DISALLOWED_SINGLE_LINE: Final = re.compile(f"[^{_char_class(_SINGLE_LINE_RANGES)}]")
58
+
59
+
60
+ def is_assignable(code_point: int) -> bool:
61
+ """True if ``code_point`` is an RFC 9839 Unicode Assignable."""
62
+ return any(low <= code_point <= high for low, high in ASSIGNABLE_RANGES)
63
+
64
+
65
+ def first_disallowed(text: str, *, multiline: bool) -> int | None:
66
+ """Index of the first character not permitted by §4.2, or None."""
67
+ pattern = _DISALLOWED_MULTILINE if multiline else _DISALLOWED_SINGLE_LINE
68
+ match = pattern.search(text)
69
+ return None if match is None else match.start()
70
+
71
+
72
+ def is_nonblank(text: str) -> bool:
73
+ """True if ``text`` has a character other than SPACE, TAB, LF, or CR."""
74
+ return any(ch not in _BLANK for ch in text)
75
+
76
+
77
+ def check_text(
78
+ value: str,
79
+ *,
80
+ multiline: bool = False,
81
+ nonblank: bool = False,
82
+ max_code_points: int | None = None,
83
+ file: str | None = None,
84
+ field: str | None = None,
85
+ collector: IssueCollector,
86
+ ) -> None:
87
+ """Check a decoded string against §4.2 and an optional length limit.
88
+
89
+ ``multiline`` permits TAB, LF, and CR (abstract, citation reason, claim note).
90
+ """
91
+ index = first_disallowed(value, multiline=multiline)
92
+ if index is not None:
93
+ ch = value[index]
94
+ if ch in LINE_CONTROLS:
95
+ collector.add(
96
+ "text_line_control",
97
+ "TAB, LF, and CR are permitted only in abstract, citation reason, "
98
+ "README.md, and LICENSE",
99
+ section="4.2",
100
+ file=file,
101
+ field=field,
102
+ observed=f"U+{ord(ch):04X} at code point {index}",
103
+ )
104
+ else:
105
+ collector.add(
106
+ "text_not_assignable",
107
+ "text must contain only RFC 9839 Unicode Assignables",
108
+ section="4.2",
109
+ file=file,
110
+ field=field,
111
+ observed=f"U+{ord(ch):04X} at code point {index}",
112
+ )
113
+ if max_code_points is not None and len(value) > max_code_points:
114
+ collector.add(
115
+ "text_too_long",
116
+ f"at most {max_code_points} Unicode code points",
117
+ section="4",
118
+ file=file,
119
+ field=field,
120
+ observed=f"{len(value)} code points",
121
+ )
122
+ if nonblank and not is_nonblank(value):
123
+ collector.add(
124
+ "text_blank",
125
+ "must contain a character other than SPACE, TAB, LF, or CR",
126
+ section="4.2",
127
+ file=file,
128
+ field=field,
129
+ )
130
+
131
+
132
+ def decode_text_file(data: bytes, *, file: str, nonblank: bool = False) -> str:
133
+ """Decode a README.md/LICENSE body and check §4.2. Raises :class:`Invalid`."""
134
+ issues = check_text_file([data], file=file, nonblank=nonblank)
135
+ if issues:
136
+ raise Invalid(issues)
137
+ return data.decode("utf-8")
138
+
139
+
140
+ def check_text_file(chunks: Iterable[bytes], *, file: str, nonblank: bool = False) -> list[Issue]:
141
+ """Stream-check UTF-8 text-file chunks against §2 and §4.2; return the issues."""
142
+ validator = TextFileValidator(file=file, nonblank=nonblank)
143
+ for chunk in chunks:
144
+ if not validator.feed(chunk):
145
+ break
146
+ return validator.finish()
147
+
148
+
149
+ class TextFileValidator:
150
+ """Incremental checker for README.md/LICENSE bodies (§2, §4.2).
151
+
152
+ Feed chunks in order, then call :meth:`finish`. Memory use is bounded by
153
+ the chunk size. At most one encoding/character issue is reported, plus a
154
+ blankness issue for README.md.
155
+ """
156
+
157
+ def __init__(self, *, file: str, nonblank: bool = False) -> None:
158
+ self.file = file
159
+ self.nonblank = nonblank
160
+ self._decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
161
+ self._collector = IssueCollector()
162
+ self._seen_nonblank = False
163
+ self._consumed = 0
164
+ self._position = 0
165
+ self._failed = False
166
+ self._finished = False
167
+
168
+ def feed(self, chunk: bytes) -> bool:
169
+ """Consume a chunk; return False once a violation has been found."""
170
+ if self._failed:
171
+ return False
172
+ try:
173
+ text = self._decoder.decode(chunk)
174
+ except UnicodeDecodeError as exc:
175
+ self._utf8_error(exc)
176
+ return False
177
+ self._consumed += len(chunk)
178
+ self._check(text)
179
+ return not self._failed
180
+
181
+ def finish(self) -> list[Issue]:
182
+ """Flush the decoder and return all issues."""
183
+ if not self._finished:
184
+ self._finished = True
185
+ if not self._failed:
186
+ try:
187
+ self._check(self._decoder.decode(b"", final=True))
188
+ except UnicodeDecodeError as exc:
189
+ self._utf8_error(exc)
190
+ if not self._failed and self.nonblank and not self._seen_nonblank:
191
+ self._collector.add(
192
+ "text_blank", f"{self.file} must be nonblank", section="2", file=self.file
193
+ )
194
+ return list(self._collector.issues)
195
+
196
+ def _check(self, text: str) -> None:
197
+ if _report_disallowed(text, self._position, self.file, self._collector):
198
+ self._failed = True
199
+ return
200
+ self._position += len(text)
201
+ if not self._seen_nonblank and is_nonblank(text):
202
+ self._seen_nonblank = True
203
+
204
+ def _utf8_error(self, exc: UnicodeDecodeError) -> None:
205
+ self._failed = True
206
+ self._collector.add(
207
+ "text_utf8",
208
+ "text files must be well-formed UTF-8",
209
+ section="2",
210
+ file=self.file,
211
+ observed=f"ill-formed sequence near byte {self._consumed + exc.start}",
212
+ )
213
+
214
+
215
+ def _report_disallowed(text: str, position: int, file: str, collector: IssueCollector) -> bool:
216
+ index = first_disallowed(text, multiline=True)
217
+ if index is None:
218
+ return False
219
+ collector.add(
220
+ "text_not_assignable",
221
+ "text must contain only RFC 9839 Unicode Assignables",
222
+ section="4.2",
223
+ file=file,
224
+ observed=f"U+{ord(text[index]):04X} at code point {position + index}",
225
+ )
226
+ return True