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/metadata.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Publication metadata ``sci.json`` (protocol §4) and its checks (§6.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from .citations import CITATION_RELATIONS, CitationTarget, parse_citation_target
|
|
11
|
+
from .errors import IssueCollector
|
|
12
|
+
from .jsonstrict import JsonObject, JsonValue, json_pointer, json_type_name, parse_json_object
|
|
13
|
+
from .text import check_text
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MAX_CATEGORIES",
|
|
17
|
+
"MAX_CITATIONS",
|
|
18
|
+
"MAX_KEYWORDS",
|
|
19
|
+
"Category",
|
|
20
|
+
"Citation",
|
|
21
|
+
"DisplayItem",
|
|
22
|
+
"Metadata",
|
|
23
|
+
"check_metadata",
|
|
24
|
+
"parse_metadata",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
MAX_TITLE: Final = 200
|
|
28
|
+
MAX_ABSTRACT: Final = 2500
|
|
29
|
+
MAX_CATEGORIES: Final = 3
|
|
30
|
+
MAX_KEYWORDS: Final = 20
|
|
31
|
+
MAX_KEYWORD: Final = 64
|
|
32
|
+
MAX_CATEGORY_CODE: Final = 128
|
|
33
|
+
MAX_CAPTION: Final = 2000
|
|
34
|
+
MAX_CITATIONS: Final = 1000
|
|
35
|
+
MAX_REASON: Final = 1000
|
|
36
|
+
_SCHEME: Final = re.compile(r"[a-z][a-z0-9.-]{0,63}")
|
|
37
|
+
_FIELDS: Final = ("title", "abstract", "categories", "keywords", "display", "citations")
|
|
38
|
+
_FILE: Final = "sci.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Category:
|
|
43
|
+
scheme: str
|
|
44
|
+
code: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class DisplayItem:
|
|
49
|
+
path: str
|
|
50
|
+
caption: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Citation:
|
|
55
|
+
target: CitationTarget
|
|
56
|
+
relations: tuple[str, ...] = ()
|
|
57
|
+
reason: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Metadata:
|
|
62
|
+
"""Validated publication metadata."""
|
|
63
|
+
|
|
64
|
+
title: str
|
|
65
|
+
abstract: str
|
|
66
|
+
categories: tuple[Category, ...] = ()
|
|
67
|
+
keywords: tuple[str, ...] = ()
|
|
68
|
+
display: tuple[DisplayItem, ...] = ()
|
|
69
|
+
citations: tuple[Citation, ...] = ()
|
|
70
|
+
raw: JsonObject = field(default_factory=dict, compare=False, repr=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_metadata(data: bytes, listed_paths: Collection[str] | None = None) -> Metadata:
|
|
74
|
+
"""Decode and check ``sci.json``. Raises :class:`~pysci.errors.Invalid` with all issues.
|
|
75
|
+
|
|
76
|
+
``listed_paths`` are the manifest's ``files`` keys used to check display
|
|
77
|
+
paths (§4.3); pass None to skip that cross-check (e.g. for drafts, where the
|
|
78
|
+
caller supplies the planned file list instead).
|
|
79
|
+
"""
|
|
80
|
+
record = parse_json_object(data, file=_FILE)
|
|
81
|
+
issues = IssueCollector()
|
|
82
|
+
metadata = check_metadata(record, listed_paths, issues)
|
|
83
|
+
issues.raise_if_any()
|
|
84
|
+
assert metadata is not None
|
|
85
|
+
return metadata
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_metadata(
|
|
89
|
+
record: JsonObject, listed_paths: Collection[str] | None, issues: IssueCollector
|
|
90
|
+
) -> Metadata | None:
|
|
91
|
+
"""Collect every §4 violation in a decoded ``sci.json`` object."""
|
|
92
|
+
start = len(issues)
|
|
93
|
+
_check_keys(record, "", issues)
|
|
94
|
+
extra = sorted(set(record) - set(_FIELDS))
|
|
95
|
+
if extra:
|
|
96
|
+
issues.add(
|
|
97
|
+
"metadata_fields",
|
|
98
|
+
"sci.json permits only title, abstract, categories, keywords, display, and citations",
|
|
99
|
+
section="4",
|
|
100
|
+
file=_FILE,
|
|
101
|
+
observed=extra,
|
|
102
|
+
)
|
|
103
|
+
title = _string(record, "title", "/title", issues, max_len=MAX_TITLE, required=True)
|
|
104
|
+
abstract = _string(
|
|
105
|
+
record, "abstract", "/abstract", issues, max_len=MAX_ABSTRACT, required=True, multiline=True
|
|
106
|
+
)
|
|
107
|
+
categories = _categories(record, issues)
|
|
108
|
+
keywords = _keywords(record, issues)
|
|
109
|
+
display = _display(record, listed_paths, issues)
|
|
110
|
+
citations = _citations(record, issues)
|
|
111
|
+
if len(issues) != start:
|
|
112
|
+
return None
|
|
113
|
+
assert title is not None
|
|
114
|
+
assert abstract is not None
|
|
115
|
+
return Metadata(
|
|
116
|
+
title=title,
|
|
117
|
+
abstract=abstract,
|
|
118
|
+
categories=categories,
|
|
119
|
+
keywords=keywords,
|
|
120
|
+
display=display,
|
|
121
|
+
citations=citations,
|
|
122
|
+
raw=record,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_keys(value: JsonValue, where: str, issues: IssueCollector) -> None:
|
|
127
|
+
"""Object keys are decoded strings and follow §4.2 (single-line rules)."""
|
|
128
|
+
stack: list[tuple[JsonValue, str]] = [(value, where)]
|
|
129
|
+
while stack:
|
|
130
|
+
item, pointer = stack.pop()
|
|
131
|
+
if isinstance(item, dict):
|
|
132
|
+
for key, child in item.items():
|
|
133
|
+
child_pointer = pointer + json_pointer(key)
|
|
134
|
+
check_text(key, file=_FILE, field=child_pointer, collector=issues)
|
|
135
|
+
stack.append((child, child_pointer))
|
|
136
|
+
elif isinstance(item, list):
|
|
137
|
+
stack.extend((child, f"{pointer}/{index}") for index, child in enumerate(item))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _string(
|
|
141
|
+
container: JsonObject,
|
|
142
|
+
name: str,
|
|
143
|
+
where: str,
|
|
144
|
+
issues: IssueCollector,
|
|
145
|
+
*,
|
|
146
|
+
max_len: int,
|
|
147
|
+
required: bool = False,
|
|
148
|
+
multiline: bool = False,
|
|
149
|
+
) -> str | None:
|
|
150
|
+
if name not in container:
|
|
151
|
+
if required:
|
|
152
|
+
issues.add(
|
|
153
|
+
"metadata_required", f"{name} is required", section="4", file=_FILE, field=where
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
value = container[name]
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
issues.add(
|
|
159
|
+
"metadata_type",
|
|
160
|
+
f"{name} must be a string",
|
|
161
|
+
section="4",
|
|
162
|
+
file=_FILE,
|
|
163
|
+
field=where,
|
|
164
|
+
observed=json_type_name(value),
|
|
165
|
+
)
|
|
166
|
+
return None
|
|
167
|
+
before = len(issues)
|
|
168
|
+
check_text(
|
|
169
|
+
value,
|
|
170
|
+
multiline=multiline,
|
|
171
|
+
nonblank=True,
|
|
172
|
+
max_code_points=max_len,
|
|
173
|
+
file=_FILE,
|
|
174
|
+
field=where,
|
|
175
|
+
collector=issues,
|
|
176
|
+
)
|
|
177
|
+
return value if len(issues) == before else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _array(
|
|
181
|
+
record: JsonObject, name: str, issues: IssueCollector, max_items: int | None
|
|
182
|
+
) -> list[JsonValue]:
|
|
183
|
+
value = record.get(name, [])
|
|
184
|
+
if not isinstance(value, list):
|
|
185
|
+
issues.add(
|
|
186
|
+
"metadata_type",
|
|
187
|
+
f"{name} must be an array",
|
|
188
|
+
section="4",
|
|
189
|
+
file=_FILE,
|
|
190
|
+
field="/" + name,
|
|
191
|
+
observed=json_type_name(value),
|
|
192
|
+
)
|
|
193
|
+
return []
|
|
194
|
+
if max_items is not None and len(value) > max_items:
|
|
195
|
+
issues.add(
|
|
196
|
+
"metadata_array_length",
|
|
197
|
+
f"{name} has at most {max_items} items",
|
|
198
|
+
section="4",
|
|
199
|
+
file=_FILE,
|
|
200
|
+
field="/" + name,
|
|
201
|
+
observed=f"{len(value)} items",
|
|
202
|
+
)
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _object(
|
|
207
|
+
item: JsonValue,
|
|
208
|
+
where: str,
|
|
209
|
+
required: tuple[str, ...],
|
|
210
|
+
optional: tuple[str, ...],
|
|
211
|
+
label: str,
|
|
212
|
+
issues: IssueCollector,
|
|
213
|
+
) -> JsonObject | None:
|
|
214
|
+
if not isinstance(item, dict):
|
|
215
|
+
issues.add(
|
|
216
|
+
"metadata_type",
|
|
217
|
+
f"each {label} must be an object",
|
|
218
|
+
section="4",
|
|
219
|
+
file=_FILE,
|
|
220
|
+
field=where,
|
|
221
|
+
observed=json_type_name(item),
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
224
|
+
missing = [name for name in required if name not in item]
|
|
225
|
+
extra = sorted(set(item) - set(required) - set(optional))
|
|
226
|
+
if missing:
|
|
227
|
+
issues.add(
|
|
228
|
+
"metadata_fields",
|
|
229
|
+
f"{label} requires {', '.join(required)}",
|
|
230
|
+
section="4",
|
|
231
|
+
file=_FILE,
|
|
232
|
+
field=where,
|
|
233
|
+
observed=missing,
|
|
234
|
+
)
|
|
235
|
+
if extra:
|
|
236
|
+
allowed = ", ".join(required + optional)
|
|
237
|
+
issues.add(
|
|
238
|
+
"metadata_fields",
|
|
239
|
+
f"{label} permits only {allowed}",
|
|
240
|
+
section="4",
|
|
241
|
+
file=_FILE,
|
|
242
|
+
field=where,
|
|
243
|
+
observed=extra,
|
|
244
|
+
)
|
|
245
|
+
return item
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _categories(record: JsonObject, issues: IssueCollector) -> tuple[Category, ...]:
|
|
249
|
+
result: list[Category] = []
|
|
250
|
+
seen: set[tuple[str, str]] = set()
|
|
251
|
+
for index, item in enumerate(_array(record, "categories", issues, MAX_CATEGORIES)):
|
|
252
|
+
where = f"/categories/{index}"
|
|
253
|
+
obj = _object(item, where, ("scheme", "code"), (), "category", issues)
|
|
254
|
+
if obj is None:
|
|
255
|
+
continue
|
|
256
|
+
scheme = obj.get("scheme")
|
|
257
|
+
if "scheme" in obj and not (isinstance(scheme, str) and _SCHEME.fullmatch(scheme)):
|
|
258
|
+
issues.add(
|
|
259
|
+
"category_scheme",
|
|
260
|
+
"scheme must match [a-z][a-z0-9.-]{0,63}",
|
|
261
|
+
section="4.1",
|
|
262
|
+
file=_FILE,
|
|
263
|
+
field=where + "/scheme",
|
|
264
|
+
observed=scheme,
|
|
265
|
+
)
|
|
266
|
+
scheme = None
|
|
267
|
+
code = _string(obj, "code", where + "/code", issues, max_len=MAX_CATEGORY_CODE)
|
|
268
|
+
if isinstance(scheme, str) and code is not None:
|
|
269
|
+
if (scheme, code) in seen:
|
|
270
|
+
issues.add(
|
|
271
|
+
"category_duplicate",
|
|
272
|
+
"duplicate (scheme, code) pairs are prohibited",
|
|
273
|
+
section="4.1",
|
|
274
|
+
file=_FILE,
|
|
275
|
+
field=where,
|
|
276
|
+
observed=[scheme, code],
|
|
277
|
+
)
|
|
278
|
+
seen.add((scheme, code))
|
|
279
|
+
result.append(Category(scheme, code))
|
|
280
|
+
return tuple(result)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _keywords(record: JsonObject, issues: IssueCollector) -> tuple[str, ...]:
|
|
284
|
+
result: list[str] = []
|
|
285
|
+
for index, item in enumerate(_array(record, "keywords", issues, MAX_KEYWORDS)):
|
|
286
|
+
value = _string({"k": item}, "k", f"/keywords/{index}", issues, max_len=MAX_KEYWORD)
|
|
287
|
+
if value is not None:
|
|
288
|
+
result.append(value)
|
|
289
|
+
return tuple(result)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _display(
|
|
293
|
+
record: JsonObject, listed_paths: Collection[str] | None, issues: IssueCollector
|
|
294
|
+
) -> tuple[DisplayItem, ...]:
|
|
295
|
+
result: list[DisplayItem] = []
|
|
296
|
+
seen: set[str] = set()
|
|
297
|
+
for index, item in enumerate(_array(record, "display", issues, None)):
|
|
298
|
+
where = f"/display/{index}"
|
|
299
|
+
obj = _object(item, where, ("path",), ("caption",), "display item", issues)
|
|
300
|
+
if obj is None:
|
|
301
|
+
continue
|
|
302
|
+
path = obj.get("path")
|
|
303
|
+
ok = True
|
|
304
|
+
if "path" in obj:
|
|
305
|
+
if (
|
|
306
|
+
not isinstance(path, str)
|
|
307
|
+
or not path.startswith("files/")
|
|
308
|
+
or (listed_paths is not None and path not in listed_paths)
|
|
309
|
+
):
|
|
310
|
+
issues.add(
|
|
311
|
+
"display_path",
|
|
312
|
+
"display path must exactly match an entry under files/ in the manifest",
|
|
313
|
+
section="4.3",
|
|
314
|
+
file=_FILE,
|
|
315
|
+
field=where + "/path",
|
|
316
|
+
observed=path,
|
|
317
|
+
)
|
|
318
|
+
ok = False
|
|
319
|
+
elif path in seen:
|
|
320
|
+
issues.add(
|
|
321
|
+
"display_duplicate",
|
|
322
|
+
"display paths must be distinct",
|
|
323
|
+
section="4.3",
|
|
324
|
+
file=_FILE,
|
|
325
|
+
field=where + "/path",
|
|
326
|
+
observed=path,
|
|
327
|
+
)
|
|
328
|
+
ok = False
|
|
329
|
+
caption = _string(obj, "caption", where + "/caption", issues, max_len=MAX_CAPTION)
|
|
330
|
+
if isinstance(path, str) and ok:
|
|
331
|
+
seen.add(path)
|
|
332
|
+
result.append(DisplayItem(path, caption))
|
|
333
|
+
return tuple(result)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _citations(record: JsonObject, issues: IssueCollector) -> tuple[Citation, ...]:
|
|
337
|
+
result: list[Citation] = []
|
|
338
|
+
for index, item in enumerate(_array(record, "citations", issues, MAX_CITATIONS)):
|
|
339
|
+
where = f"/citations/{index}"
|
|
340
|
+
obj = _object(item, where, ("target",), ("relations", "reason"), "citation", issues)
|
|
341
|
+
if obj is None:
|
|
342
|
+
continue
|
|
343
|
+
target_value = obj.get("target")
|
|
344
|
+
target: CitationTarget | None = None
|
|
345
|
+
if "target" in obj:
|
|
346
|
+
if not isinstance(target_value, str):
|
|
347
|
+
issues.add(
|
|
348
|
+
"citation_target",
|
|
349
|
+
"target must be a string",
|
|
350
|
+
section="4.4",
|
|
351
|
+
file=_FILE,
|
|
352
|
+
field=where + "/target",
|
|
353
|
+
observed=json_type_name(target_value),
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
check_text(target_value, file=_FILE, field=where + "/target", collector=issues)
|
|
357
|
+
target = parse_citation_target(target_value)
|
|
358
|
+
if target is None:
|
|
359
|
+
issues.add(
|
|
360
|
+
"citation_target",
|
|
361
|
+
"target must be sci:sha256:<hash>, a doi: URI, or an http(s) URI",
|
|
362
|
+
section="4.4",
|
|
363
|
+
file=_FILE,
|
|
364
|
+
field=where + "/target",
|
|
365
|
+
observed=target_value,
|
|
366
|
+
)
|
|
367
|
+
relations: tuple[str, ...] = ()
|
|
368
|
+
if "relations" in obj:
|
|
369
|
+
value = obj["relations"]
|
|
370
|
+
if not (isinstance(value, list) and all(isinstance(r, str) for r in value)):
|
|
371
|
+
issues.add(
|
|
372
|
+
"citation_relations",
|
|
373
|
+
"relations must be an array of strings",
|
|
374
|
+
section="4.4",
|
|
375
|
+
file=_FILE,
|
|
376
|
+
field=where + "/relations",
|
|
377
|
+
observed=json_type_name(value),
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
names = [r for r in value if isinstance(r, str)]
|
|
381
|
+
unknown = sorted(set(names) - CITATION_RELATIONS)
|
|
382
|
+
if unknown:
|
|
383
|
+
issues.add(
|
|
384
|
+
"citation_relations",
|
|
385
|
+
"relations must come from uses, reviews, supports, challenges, supersedes",
|
|
386
|
+
section="4.4",
|
|
387
|
+
file=_FILE,
|
|
388
|
+
field=where + "/relations",
|
|
389
|
+
observed=unknown,
|
|
390
|
+
)
|
|
391
|
+
if len(set(names)) != len(names):
|
|
392
|
+
issues.add(
|
|
393
|
+
"citation_relations",
|
|
394
|
+
"relations must be distinct",
|
|
395
|
+
section="4.4",
|
|
396
|
+
file=_FILE,
|
|
397
|
+
field=where + "/relations",
|
|
398
|
+
observed=names,
|
|
399
|
+
)
|
|
400
|
+
relations = tuple(names)
|
|
401
|
+
reason = _string(
|
|
402
|
+
obj, "reason", where + "/reason", issues, max_len=MAX_REASON, multiline=True
|
|
403
|
+
)
|
|
404
|
+
if target is not None:
|
|
405
|
+
result.append(Citation(target, relations, reason))
|
|
406
|
+
return tuple(result)
|
pysci/paths.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Bundle file path rules (protocol §3.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from .errors import IssueCollector
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MAX_PATH_BYTES",
|
|
13
|
+
"check_path",
|
|
14
|
+
"check_path_conflicts",
|
|
15
|
+
"check_paths",
|
|
16
|
+
"is_valid_path",
|
|
17
|
+
"path_sort_key",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
MAX_PATH_BYTES: Final = 255
|
|
21
|
+
_COMPONENT: Final = re.compile(r"[A-Za-z0-9._-]+")
|
|
22
|
+
_DEVICE: Final = re.compile(r"(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", re.IGNORECASE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def path_sort_key(path: str) -> bytes:
|
|
26
|
+
"""Ordering key: ascending lexicographic order of the ASCII path bytes."""
|
|
27
|
+
return path.encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _path_problem(path: str) -> tuple[str, str] | None:
|
|
31
|
+
if not path:
|
|
32
|
+
return ("path_empty", "path must not be empty")
|
|
33
|
+
if not path.isascii():
|
|
34
|
+
return ("path_component", "paths use only ASCII letters, digits, '.', '_', and '-'")
|
|
35
|
+
if len(path.encode("ascii")) > MAX_PATH_BYTES:
|
|
36
|
+
return ("path_too_long", f"full path must be at most {MAX_PATH_BYTES} ASCII bytes")
|
|
37
|
+
for component in path.split("/"):
|
|
38
|
+
if not component:
|
|
39
|
+
return (
|
|
40
|
+
"path_empty_component",
|
|
41
|
+
"path components must be nonempty (no leading, trailing, or repeated '/')",
|
|
42
|
+
)
|
|
43
|
+
if not _COMPONENT.fullmatch(component):
|
|
44
|
+
return (
|
|
45
|
+
"path_component",
|
|
46
|
+
"components use only ASCII letters, digits, '.', '_', and '-'",
|
|
47
|
+
)
|
|
48
|
+
if component in (".", "..") or component.endswith("."):
|
|
49
|
+
return ("path_dot_component", "'.', '..', and components ending in '.' are prohibited")
|
|
50
|
+
if _DEVICE.fullmatch(component):
|
|
51
|
+
return ("path_device_name", "Windows device names are prohibited in any component")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_valid_path(path: str) -> bool:
|
|
56
|
+
"""True if ``path`` satisfies the per-path rules of §3.1."""
|
|
57
|
+
return _path_problem(path) is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_path(
|
|
61
|
+
path: str, *, collector: IssueCollector, file: str | None = None, field: str | None = None
|
|
62
|
+
) -> bool:
|
|
63
|
+
"""Check one path; append an issue and return False on failure."""
|
|
64
|
+
problem = _path_problem(path)
|
|
65
|
+
if problem is None:
|
|
66
|
+
return True
|
|
67
|
+
code, requirement = problem
|
|
68
|
+
collector.add(code, requirement, section="3.1", file=file, field=field, observed=path)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_path_conflicts(
|
|
73
|
+
paths: Iterable[str],
|
|
74
|
+
*,
|
|
75
|
+
collector: IssueCollector,
|
|
76
|
+
file: str | None = None,
|
|
77
|
+
field: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Case-insensitive file/file, file/directory, and directory-spelling conflicts."""
|
|
80
|
+
files: dict[str, str] = {}
|
|
81
|
+
directories: dict[str, str] = {}
|
|
82
|
+
for path in paths:
|
|
83
|
+
folded = path.lower()
|
|
84
|
+
if folded in files:
|
|
85
|
+
collector.add(
|
|
86
|
+
"path_conflict",
|
|
87
|
+
"paths must differ without regard to ASCII case",
|
|
88
|
+
section="3.1",
|
|
89
|
+
file=file,
|
|
90
|
+
field=field,
|
|
91
|
+
observed=[files[folded], path],
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
files[folded] = path
|
|
95
|
+
components = path.split("/")
|
|
96
|
+
for depth in range(1, len(components)):
|
|
97
|
+
directory = "/".join(components[:depth])
|
|
98
|
+
existing = directories.setdefault(directory.lower(), directory)
|
|
99
|
+
if existing != directory:
|
|
100
|
+
collector.add(
|
|
101
|
+
"path_directory_case",
|
|
102
|
+
"every occurrence of a directory must use the same capitalization",
|
|
103
|
+
section="3.1",
|
|
104
|
+
file=file,
|
|
105
|
+
field=field,
|
|
106
|
+
observed=[existing, directory],
|
|
107
|
+
)
|
|
108
|
+
for folded, directory in directories.items():
|
|
109
|
+
if folded in files:
|
|
110
|
+
collector.add(
|
|
111
|
+
"path_file_directory_conflict",
|
|
112
|
+
"a file path must not also name a directory used by another entry",
|
|
113
|
+
section="3.1",
|
|
114
|
+
file=file,
|
|
115
|
+
field=field,
|
|
116
|
+
observed=[files[folded], directory],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_paths(
|
|
121
|
+
paths: Iterable[str],
|
|
122
|
+
*,
|
|
123
|
+
collector: IssueCollector,
|
|
124
|
+
file: str | None = None,
|
|
125
|
+
field_prefix: str | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Per-path rules plus conflict checks for a full set of paths."""
|
|
128
|
+
path_list = list(paths)
|
|
129
|
+
for path in path_list:
|
|
130
|
+
field = (
|
|
131
|
+
None
|
|
132
|
+
if field_prefix is None
|
|
133
|
+
else field_prefix + "/" + path.replace("~", "~0").replace("/", "~1")
|
|
134
|
+
)
|
|
135
|
+
check_path(path, collector=collector, file=file, field=field)
|
|
136
|
+
check_path_conflicts(path_list, collector=collector, file=file, field=field_prefix)
|