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/citations.py ADDED
@@ -0,0 +1,233 @@
1
+ """Citation targets (protocol §4.4): validation and comparison keys.
2
+
3
+ Three target forms are permitted:
4
+
5
+ * ``sci:sha256:<digest>`` -- an SCI publication;
6
+ * ``doi:<name>`` -- a DOI URI (DOI names compare case-insensitively for ASCII
7
+ after percent-decoding);
8
+ * an ``http``/``https`` URI (RFC 9110 §4.2) with an optional fragment.
9
+
10
+ :func:`citation_target_key` derives a normalized key so that equivalent
11
+ spellings of the same DOI or URL can be grouped, e.g. by an indexer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ipaddress
17
+ import re
18
+ from dataclasses import dataclass
19
+ from enum import StrEnum
20
+ from typing import Final
21
+
22
+ from .ids import is_digest
23
+
24
+ __all__ = [
25
+ "CITATION_RELATIONS",
26
+ "CitationTarget",
27
+ "TargetKind",
28
+ "citation_target_key",
29
+ "is_http_uri",
30
+ "parse_citation_target",
31
+ ]
32
+
33
+ CITATION_RELATIONS: Final = frozenset({"uses", "reviews", "supports", "challenges", "supersedes"})
34
+
35
+ _UNRESERVED: Final = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
36
+ _SUB_DELIMS: Final = frozenset("!$&'()*+,;=")
37
+ _PCHAR: Final = _UNRESERVED | _SUB_DELIMS | frozenset(":@")
38
+ _PATH_CHARS: Final = _PCHAR | frozenset("/")
39
+ _QUERY_CHARS: Final = _PCHAR | frozenset("/?")
40
+ _REG_NAME_CHARS: Final = _UNRESERVED | _SUB_DELIMS
41
+ _HEX: Final = frozenset("0123456789abcdefABCDEF")
42
+
43
+ _HTTP_URI: Final = re.compile(
44
+ r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*)://(?P<authority>[^/?#]*)(?P<path>[^?#]*)"
45
+ r"(?:\?(?P<query>[^#]*))?(?:#(?P<fragment>.*))?",
46
+ re.DOTALL,
47
+ )
48
+ _DOI: Final = re.compile(r"doi:(?P<prefix>10\.[0-9]+(?:\.[0-9]+)*)/(?P<suffix>.+)", re.DOTALL)
49
+
50
+
51
+ class TargetKind(StrEnum):
52
+ SCI = "sci"
53
+ DOI = "doi"
54
+ URL = "url"
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class CitationTarget:
59
+ """A validated citation target and its comparison key."""
60
+
61
+ kind: TargetKind
62
+ target: str
63
+ key: str
64
+
65
+
66
+ def _valid_chars(value: str, allowed: frozenset[str]) -> bool:
67
+ """True if ``value`` uses only ``allowed`` characters and valid %HH escapes."""
68
+ index = 0
69
+ length = len(value)
70
+ while index < length:
71
+ ch = value[index]
72
+ if ch == "%":
73
+ if index + 2 >= length:
74
+ return False
75
+ if value[index + 1] not in _HEX or value[index + 2] not in _HEX:
76
+ return False
77
+ index += 3
78
+ continue
79
+ if ch not in allowed:
80
+ return False
81
+ index += 1
82
+ return True
83
+
84
+
85
+ def _valid_authority(authority: str) -> bool:
86
+ if not authority or "@" in authority:
87
+ # RFC 9110 §4.2.1: an empty host is invalid; userinfo is deprecated
88
+ # and treated as an error by recipients (§4.2.4).
89
+ return False
90
+ if authority.startswith("["):
91
+ end = authority.find("]")
92
+ if end < 0:
93
+ return False
94
+ literal, rest = authority[1:end], authority[end + 1 :]
95
+ try:
96
+ ipaddress.IPv6Address(literal)
97
+ except ValueError:
98
+ return False
99
+ port = rest[1:] if rest.startswith(":") else None
100
+ if rest and port is None:
101
+ return False
102
+ else:
103
+ host, sep, port_text = authority.partition(":")
104
+ port = port_text if sep else None
105
+ if not host or not _valid_chars(host, _REG_NAME_CHARS):
106
+ return False
107
+ return port is None or port.isdigit() or port == ""
108
+
109
+
110
+ def is_http_uri(value: str) -> bool:
111
+ """True for an absolute ``http``/``https`` URI with an optional fragment."""
112
+ match = _HTTP_URI.fullmatch(value)
113
+ if match is None or match["scheme"].lower() not in ("http", "https"):
114
+ return False
115
+ if not value.isascii():
116
+ return False
117
+ if not _valid_authority(match["authority"]):
118
+ return False
119
+ if not _valid_chars(match["path"], _PATH_CHARS):
120
+ return False
121
+ query = match["query"]
122
+ if query is not None and not _valid_chars(query, _QUERY_CHARS):
123
+ return False
124
+ fragment = match["fragment"]
125
+ return fragment is None or _valid_chars(fragment, _QUERY_CHARS)
126
+
127
+
128
+ def _percent_normalize(value: str, keep: frozenset[str], *, fold_case: bool = False) -> str:
129
+ """Decode %HH into bytes, optionally ASCII-lowercase, and re-encode canonically.
130
+
131
+ Characters in ``keep`` (and unreserved characters) are written literally;
132
+ every other byte becomes an uppercase %HH escape.
133
+ """
134
+ raw = bytearray()
135
+ index = 0
136
+ while index < len(value):
137
+ ch = value[index]
138
+ if ch == "%":
139
+ raw.append(int(value[index + 1 : index + 3], 16))
140
+ index += 3
141
+ else:
142
+ raw.extend(ch.encode("utf-8"))
143
+ index += 1
144
+ if fold_case:
145
+ raw = bytearray(raw.lower()) # bytes.lower() affects ASCII letters only
146
+ out = []
147
+ literal = keep | _UNRESERVED
148
+ for byte in raw:
149
+ ch = chr(byte)
150
+ out.append(ch if byte < 0x80 and ch in literal else f"%{byte:02X}")
151
+ return "".join(out)
152
+
153
+
154
+ def _normalize_escapes(value: str, *, lower: bool = False) -> str:
155
+ """RFC 3986 §6.2.2: uppercase %HH, decode escaped unreserved characters only."""
156
+ out: list[str] = []
157
+ index = 0
158
+ while index < len(value):
159
+ ch = value[index]
160
+ if ch == "%":
161
+ byte = int(value[index + 1 : index + 3], 16)
162
+ decoded = chr(byte)
163
+ if decoded in _UNRESERVED:
164
+ out.append(decoded.lower() if lower else decoded)
165
+ else:
166
+ out.append(f"%{byte:02X}")
167
+ index += 3
168
+ else:
169
+ out.append(ch.lower() if lower else ch)
170
+ index += 1
171
+ return "".join(out)
172
+
173
+
174
+ def _url_key(value: str) -> str:
175
+ match = _HTTP_URI.fullmatch(value)
176
+ if match is None: # pragma: no cover - validated before
177
+ raise ValueError("not an HTTP URI")
178
+ scheme = match["scheme"].lower()
179
+ authority = match["authority"]
180
+ if authority.startswith("["):
181
+ end = authority.index("]")
182
+ host = "[" + str(ipaddress.IPv6Address(authority[1:end])) + "]"
183
+ port = authority[end + 2 :] if authority[end + 1 :].startswith(":") else ""
184
+ else:
185
+ host_text, _, port = authority.partition(":")
186
+ host = _normalize_escapes(host_text, lower=True)
187
+ if port and port.lstrip("0") == ("80" if scheme == "http" else "443"):
188
+ port = ""
189
+ port = port.lstrip("0") or ("0" if port else "")
190
+ path = _normalize_escapes(match["path"]) or "/"
191
+ key = f"{scheme}://{host}{':' + port if port else ''}{path}"
192
+ if match["query"] is not None:
193
+ key += "?" + _normalize_escapes(match["query"])
194
+ if match["fragment"] is not None:
195
+ key += "#" + _normalize_escapes(match["fragment"])
196
+ return key
197
+
198
+
199
+ def parse_citation_target(target: str) -> CitationTarget | None:
200
+ """Validate a §4.4 target; None if it uses none of the permitted forms."""
201
+ if target.startswith("sci:"):
202
+ digest = target[len("sci:sha256:") :] if target.startswith("sci:sha256:") else ""
203
+ if not is_digest(digest):
204
+ return None
205
+ return CitationTarget(TargetKind.SCI, target, target)
206
+ if target.startswith("doi:"):
207
+ match = _DOI.fullmatch(target)
208
+ if match is None or not target.isascii() or not _valid_chars(match["suffix"], _PATH_CHARS):
209
+ return None
210
+ suffix = _percent_normalize(match["suffix"], _PATH_CHARS - _UNRESERVED, fold_case=True)
211
+ return CitationTarget(TargetKind.DOI, target, f"doi:{match['prefix']}/{suffix}")
212
+ if is_http_uri(target):
213
+ return CitationTarget(TargetKind.URL, target, _url_key(target))
214
+ return None
215
+
216
+
217
+ def citation_target_key(target: str) -> str:
218
+ """Normalized comparison key for a citation target.
219
+
220
+ * SCI targets are returned unchanged (they are already canonical).
221
+ * DOI targets are percent-decoded, ASCII-lowercased, and re-encoded with
222
+ uppercase escapes for every byte outside RFC 3986 ``pchar``/``/``.
223
+ * URLs get RFC 3986 §6.2.2-6.2.3 normalization: lowercase scheme and host,
224
+ default port removed, empty path as ``/``, percent-encoding normalized.
225
+ The fragment is retained.
226
+
227
+ Raises:
228
+ ValueError: ``target`` is not a valid citation target.
229
+ """
230
+ parsed = parse_citation_target(target)
231
+ if parsed is None:
232
+ raise ValueError("not a valid SCI, DOI, or HTTP(S) citation target")
233
+ return parsed.key
@@ -0,0 +1,45 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIH/zCCBeegAwIBAgIJAMHphhYNqOmAMA0GCSqGSIb3DQEBDQUAMIGVMREwDwYD
3
+ VQQKEwhGcmVlIFRTQTEQMA4GA1UECxMHUm9vdCBDQTEYMBYGA1UEAxMPd3d3LmZy
4
+ ZWV0c2Eub3JnMSIwIAYJKoZIhvcNAQkBFhNidXNpbGV6YXNAZ21haWwuY29tMRIw
5
+ EAYDVQQHEwlXdWVyemJ1cmcxDzANBgNVBAgTBkJheWVybjELMAkGA1UEBhMCREUw
6
+ HhcNMTYwMzEzMDE1MjEzWhcNNDEwMzA3MDE1MjEzWjCBlTERMA8GA1UEChMIRnJl
7
+ ZSBUU0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9y
8
+ ZzEiMCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJ
9
+ V3VlcnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFMIICIjANBgkq
10
+ hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtgKODjAy8REQ2WTNqUudAnjhlCrpE6ql
11
+ mQfNppeTmVvZrH4zutn+NwTaHAGpjSGv4/WRpZ1wZ3BRZ5mPUBZyLgq0YrIfQ5Fx
12
+ 0s/MRZPzc1r3lKWrMR9sAQx4mN4z11xFEO529L0dFJjPF9MD8Gpd2feWzGyptlel
13
+ b+PqT+++fOa2oY0+NaMM7l/xcNHPOaMz0/2olk0i22hbKeVhvokPCqhFhzsuhKsm
14
+ q4Of/o+t6dI7sx5h0nPMm4gGSRhfq+z6BTRgCrqQG2FOLoVFgt6iIm/BnNffUr7V
15
+ DYd3zZmIwFOj/H3DKHoGik/xK3E82YA2ZulVOFRW/zj4ApjPa5OFbpIkd0pmzxzd
16
+ EcL479hSA9dFiyVmSxPtY5ze1P+BE9bMU1PScpRzw8MHFXxyKqW13Qv7LWw4sbk3
17
+ SciB7GACbQiVGzgkvXG6y85HOuvWNvC5GLSiyP9GlPB0V68tbxz4JVTRdw/Xn/XT
18
+ FNzRBM3cq8lBOAVt/PAX5+uFcv1S9wFE8YjaBfWCP1jdBil+c4e+0tdywT2oJmYB
19
+ BF/kEt1wmGwMmHunNEuQNzh1FtJY54hbUfiWi38mASE7xMtMhfj/C4SvapiDN837
20
+ gYaPfs8x3KZxbX7C3YAsFnJinlwAUss1fdKar8Q/YVs7H/nU4c4Ixxxz4f67fcVq
21
+ M2ITKentbCMCAwEAAaOCAk4wggJKMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQD
22
+ AgHGMB0GA1UdDgQWBBT6VQ2MNGZRQ0z357OnbJWveuaklzCBygYDVR0jBIHCMIG/
23
+ gBT6VQ2MNGZRQ0z357OnbJWveuakl6GBm6SBmDCBlTERMA8GA1UEChMIRnJlZSBU
24
+ U0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9yZzEi
25
+ MCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJV3Vl
26
+ cnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFggkAwemGFg2o6YAw
27
+ MwYDVR0fBCwwKjAooCagJIYiaHR0cDovL3d3dy5mcmVldHNhLm9yZy9yb290X2Nh
28
+ LmNybDCBzwYDVR0gBIHHMIHEMIHBBgorBgEEAYHyJAEBMIGyMDMGCCsGAQUFBwIB
29
+ FidodHRwOi8vd3d3LmZyZWV0c2Eub3JnL2ZyZWV0c2FfY3BzLmh0bWwwMgYIKwYB
30
+ BQUHAgEWJmh0dHA6Ly93d3cuZnJlZXRzYS5vcmcvZnJlZXRzYV9jcHMucGRmMEcG
31
+ CCsGAQUFBwICMDsaOUZyZWVUU0EgdHJ1c3RlZCB0aW1lc3RhbXBpbmcgU29mdHdh
32
+ cmUgYXMgYSBTZXJ2aWNlIChTYWFTKTA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUH
33
+ MAGGG2h0dHA6Ly93d3cuZnJlZXRzYS5vcmc6MjU2MDANBgkqhkiG9w0BAQ0FAAOC
34
+ AgEAaK9+v5OFYu9M6ztYC+L69sw1omdyli89lZAfpWMMh9CRmJhM6KBqM/ipwoLt
35
+ nxyxGsbCPhcQjuTvzm+ylN6VwTMmIlVyVSLKYZcdSjt/eCUN+41K7sD7GVmxZBAF
36
+ ILnBDmTGJmLkrU0KuuIpj8lI/E6Z6NnmuP2+RAQSHsfBQi6sssnXMo4HOW5gtPO7
37
+ gDrUpVXID++1P4XndkoKn7Svw5n0zS9fv1hxBcYIHPPQUze2u30bAQt0n0iIyRLz
38
+ aWuhtpAtd7ffwEbASgzB7E+NGF4tpV37e8KiA2xiGSRqT5ndu28fgpOY87gD3ArZ
39
+ DctZvvTCfHdAS5kEO3gnGGeZEVLDmfEsv8TGJa3AljVa5E40IQDsUXpQLi8G+UC4
40
+ 1DWZu8EVT4rnYaCw1VX7ShOR1PNCCvjb8S8tfdudd9zhU3gEB0rxdeTy1tVbNLXW
41
+ 99y90xcwr1ZIDUwM/xQ/noO8FRhm0LoPC73Ef+J4ZBdrvWwauF3zJe33d4ibxEcb
42
+ 8/pz5WzFkeixYM2nsHhqHsBKw7JPouKNXRnl5IAE1eFmqDyC7G/VT7OF669xM6hb
43
+ Ut5G21JE4cNK6NNucS+fzg1JPX0+3VhsYZjj7D5uljRvQXrJ8iHgr/M6j2oLHvTA
44
+ I2MLdq2qjZFDOCXsxBxJpbmLGBx9ow6ZerlUxzws2AWv2pk=
45
+ -----END CERTIFICATE-----