sslabdata 3.0.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.
- sslabdata/__init__.py +40 -0
- sslabdata/assembler.py +452 -0
- sslabdata/cli.py +274 -0
- sslabdata/config.py +443 -0
- sslabdata/diagnostics.py +159 -0
- sslabdata/exporters.py +86 -0
- sslabdata/loaders.py +298 -0
- sslabdata/models.py +342 -0
- sslabdata/parsers/__init__.py +0 -0
- sslabdata/parsers/bibtex.py +1132 -0
- sslabdata/parsers/latex.py +215 -0
- sslabdata/resolver.py +517 -0
- sslabdata/schema/__init__.py +0 -0
- sslabdata/schema/v5/output.schema.json +458 -0
- sslabdata-3.0.0.dist-info/METADATA +409 -0
- sslabdata-3.0.0.dist-info/RECORD +20 -0
- sslabdata-3.0.0.dist-info/WHEEL +5 -0
- sslabdata-3.0.0.dist-info/entry_points.txt +2 -0
- sslabdata-3.0.0.dist-info/licenses/LICENSE +21 -0
- sslabdata-3.0.0.dist-info/top_level.txt +1 -0
sslabdata/config.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for sslabdata.
|
|
3
|
+
|
|
4
|
+
Single-layer configuration loaded from a YAML file (lab.yaml).
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2024 Personal Robotics Laboratory, University of Washington
|
|
7
|
+
Author: Siddhartha Srinivasa
|
|
8
|
+
MIT License - see LICENSE file for details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import unicodedata
|
|
15
|
+
import yaml
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import date
|
|
18
|
+
from typing import (Dict, List, NamedTuple, NoReturn, Optional, Sequence,
|
|
19
|
+
Tuple, Union)
|
|
20
|
+
from pathlib import Path, PureWindowsPath
|
|
21
|
+
|
|
22
|
+
from .diagnostics import Diagnostic, diagnostic
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A `.bib` name is emitted as `work.source.file`, never absolute (SPEC.md §5).
|
|
26
|
+
# Both path flavours are checked, so a configuration is accepted or rejected
|
|
27
|
+
# alike wherever it is compiled.
|
|
28
|
+
BIB_FILE_ABSOLUTE = "CONFIG-BIB-FILE-ABSOLUTE"
|
|
29
|
+
|
|
30
|
+
BIB_FILE_OUTSIDE = "CONFIG-BIB-FILE-OUTSIDE-BIB-DIR"
|
|
31
|
+
|
|
32
|
+
NOT_A_MAPPING = "CONFIG-NOT-A-MAPPING"
|
|
33
|
+
KEY_MISSING = "CONFIG-KEY-MISSING"
|
|
34
|
+
TYPE_INVALID = "CONFIG-TYPE-INVALID"
|
|
35
|
+
|
|
36
|
+
# Every key `lab.yaml` may hold; any other is reported. `site` is read by
|
|
37
|
+
# renderers, not by sslabdata, and is accepted without being checked.
|
|
38
|
+
KNOWN_KEYS = ("lab", "site", "bib_dir", "bib_files", "pdf_base_url",
|
|
39
|
+
"people_file", "projects_file", "collaborators_file")
|
|
40
|
+
|
|
41
|
+
# The keys whose value, when present, must be a string: a path or a URL.
|
|
42
|
+
_STRING_KEYS = ("pdf_base_url", "people_file", "projects_file",
|
|
43
|
+
"collaborators_file")
|
|
44
|
+
|
|
45
|
+
# The `lab` keys the schema types (`lab` is otherwise copied through open), by
|
|
46
|
+
# accepted YAML type. An empty value is not one: `name:` with nothing after it
|
|
47
|
+
# is null, and null is not a string.
|
|
48
|
+
_LAB_TYPES = {**dict.fromkeys(("name", "description", "institution",
|
|
49
|
+
"department", "website", "email", "address",
|
|
50
|
+
"logo"), str), "links": dict}
|
|
51
|
+
|
|
52
|
+
# `lab` reaches both formats of the document, so every value in it must have
|
|
53
|
+
# one JSON form that YAML writes alike (SPEC.md "Diagnostic codes").
|
|
54
|
+
VALUE_NOT_JSON = "CONFIG-VALUE-NOT-JSON"
|
|
55
|
+
|
|
56
|
+
KEY_REPEATED = "CONFIG-KEY-REPEATED"
|
|
57
|
+
|
|
58
|
+
MERGE_TAG = "tag:yaml.org,2002:merge"
|
|
59
|
+
|
|
60
|
+
# The control characters that are not text (SPEC.md §2). They are removed
|
|
61
|
+
# where the input is read, before anything else sees the value: U+0001 and
|
|
62
|
+
# U+0002 are also the LaTeX conversion's own markers.
|
|
63
|
+
CONTROL_CHARACTER = "TEXT-CONTROL-CHARACTER"
|
|
64
|
+
_CONTROL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def without_control_characters(text: str) -> Tuple[str, List[str]]:
|
|
68
|
+
"""``text`` with its control characters removed, and each character
|
|
69
|
+
removed, once, as ``U+XXXX``, in the order first found."""
|
|
70
|
+
found = list(dict.fromkeys(f"U+{ord(c):04X}" for c in _CONTROL.findall(text)))
|
|
71
|
+
return (_CONTROL.sub("", text) if found else text), found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def nfc(text: str) -> str:
|
|
75
|
+
"""``text`` in Unicode Normalization Form C (SPEC.md §2).
|
|
76
|
+
|
|
77
|
+
Applied where the input is read, after control characters are removed,
|
|
78
|
+
whose removal can leave a letter and its combining mark side by side. A
|
|
79
|
+
canonically equivalent spelling is the same text, so nothing is reported.
|
|
80
|
+
"""
|
|
81
|
+
return unicodedata.normalize("NFC", text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def control_message(found: List[str]) -> str:
|
|
85
|
+
"""What a control-character diagnostic says, for every input."""
|
|
86
|
+
what = ("is a control character" if len(found) == 1
|
|
87
|
+
else "are control characters")
|
|
88
|
+
return (f"{', '.join(found)} {what}, not text; removed from this value, "
|
|
89
|
+
"and the rest of it is kept")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RepeatedKey(NamedTuple):
|
|
93
|
+
"""A key given again in a mapping it is already in.
|
|
94
|
+
|
|
95
|
+
``path`` leads from the document's root to that mapping: a mapping key
|
|
96
|
+
as text, a list index as an integer. ``line`` is where the key is given
|
|
97
|
+
again and ``first_line`` where it was first given, both counted from 1.
|
|
98
|
+
"""
|
|
99
|
+
path: Tuple[Union[str, int], ...]
|
|
100
|
+
key: str
|
|
101
|
+
line: int
|
|
102
|
+
first_line: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ControlCharacters(NamedTuple):
|
|
106
|
+
"""The control characters removed from one scalar, as ``U+XXXX``.
|
|
107
|
+
|
|
108
|
+
``path`` leads from the document's root to the scalar, as a
|
|
109
|
+
`RepeatedKey`'s does; for a mapping key, it ends at that key.
|
|
110
|
+
"""
|
|
111
|
+
path: Tuple[Union[str, int], ...]
|
|
112
|
+
found: List[str]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class YAMLLoader(yaml.SafeLoader):
|
|
116
|
+
"""PyYAML's safe loader, which also records every repeated mapping key
|
|
117
|
+
-- PyYAML alone keeps the last value silently -- and removes and records
|
|
118
|
+
every control character, and puts every string in NFC.
|
|
119
|
+
|
|
120
|
+
Every YAML file sslabdata reads is read with it, through `read_yaml()`.
|
|
121
|
+
Keys are compared as the loader constructs them, so `1` and `0x1`, which
|
|
122
|
+
one dict would hold as one key, are a repeat too.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, stream):
|
|
126
|
+
super().__init__(stream)
|
|
127
|
+
self.repeated: List[RepeatedKey] = []
|
|
128
|
+
self.controls: List[ControlCharacters] = []
|
|
129
|
+
|
|
130
|
+
def construct_scalar(self, node):
|
|
131
|
+
return nfc(without_control_characters(super().construct_scalar(node))[0])
|
|
132
|
+
|
|
133
|
+
def construct_document(self, node):
|
|
134
|
+
seen = set()
|
|
135
|
+
|
|
136
|
+
def controls(node, path):
|
|
137
|
+
found = without_control_characters(node.value)[1]
|
|
138
|
+
if found:
|
|
139
|
+
self.controls.append(ControlCharacters(path, found))
|
|
140
|
+
|
|
141
|
+
def walk(node, path):
|
|
142
|
+
# An alias is the node it names, so each node is walked once,
|
|
143
|
+
# where it first appears, and a recursive alias ends the walk.
|
|
144
|
+
if id(node) in seen:
|
|
145
|
+
return
|
|
146
|
+
seen.add(id(node))
|
|
147
|
+
if isinstance(node, yaml.ScalarNode):
|
|
148
|
+
controls(node, path)
|
|
149
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
150
|
+
for index, child in enumerate(node.value):
|
|
151
|
+
walk(child, (*path, index))
|
|
152
|
+
elif isinstance(node, yaml.MappingNode):
|
|
153
|
+
first: Dict[object, int] = {}
|
|
154
|
+
for key_node, value_node in node.value:
|
|
155
|
+
if key_node.tag == MERGE_TAG:
|
|
156
|
+
walk(value_node, (*path, "<<"))
|
|
157
|
+
continue
|
|
158
|
+
# A key that is not a scalar cannot be a dict key;
|
|
159
|
+
# constructing the mapping reports it.
|
|
160
|
+
if not isinstance(key_node, yaml.ScalarNode):
|
|
161
|
+
continue
|
|
162
|
+
key = self.construct_object(key_node)
|
|
163
|
+
controls(key_node, (*path, str(key)))
|
|
164
|
+
line = key_node.start_mark.line + 1
|
|
165
|
+
if key in first:
|
|
166
|
+
self.repeated.append(RepeatedKey(
|
|
167
|
+
path, str(key), line, first[key]))
|
|
168
|
+
else:
|
|
169
|
+
first[key] = line
|
|
170
|
+
walk(value_node, (*path, str(key)))
|
|
171
|
+
|
|
172
|
+
walk(node, ())
|
|
173
|
+
return super().construct_document(node)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_yaml(stream) -> Tuple[object, List[RepeatedKey],
|
|
177
|
+
List[ControlCharacters]]:
|
|
178
|
+
"""One YAML document, every key repeated in a mapping of it, and every
|
|
179
|
+
scalar its control characters were removed from."""
|
|
180
|
+
loader = YAMLLoader(stream)
|
|
181
|
+
try:
|
|
182
|
+
return loader.get_single_data(), loader.repeated, loader.controls
|
|
183
|
+
finally:
|
|
184
|
+
loader.dispose()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def dotted(path: Sequence[Union[str, int]]) -> str:
|
|
188
|
+
"""A path as a diagnostic's field names it: keys joined by `.`, with a
|
|
189
|
+
list member's index in brackets (`links.scores[1]`)."""
|
|
190
|
+
text = ""
|
|
191
|
+
for step in path:
|
|
192
|
+
text += (f"[{step}]" if isinstance(step, int)
|
|
193
|
+
else f".{step}" if text else step)
|
|
194
|
+
return text
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def repeated_message(repeat: RepeatedKey) -> str:
|
|
198
|
+
"""What a repeated key's diagnostic says, in every file."""
|
|
199
|
+
return (f"'{repeat.key}' is given at line {repeat.first_line} and again "
|
|
200
|
+
f"at line {repeat.line} of one mapping; YAML would silently keep "
|
|
201
|
+
"the last value")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ConfigurationError(ValueError):
|
|
205
|
+
"""A configuration sslabdata will not compile from (SPEC.md §1). Its
|
|
206
|
+
message is one coded diagnostic line."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def is_absolute_path(name: str) -> bool:
|
|
210
|
+
"""True when ``name`` is rooted rather than relative to ``bib_dir``.
|
|
211
|
+
|
|
212
|
+
A leading separator counts: ``\\x.bib`` is rooted on Windows even though
|
|
213
|
+
Python does not call it absolute without a drive.
|
|
214
|
+
"""
|
|
215
|
+
return name.startswith(("/", "\\")) or PureWindowsPath(name).is_absolute()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def reject_absolute_name(name, file: Optional[str] = None) -> None:
|
|
219
|
+
"""Raise when a configured `.bib` name would reach the document absolute,
|
|
220
|
+
located at `<file>:bib_files:name`."""
|
|
221
|
+
if isinstance(name, str) and is_absolute_path(name):
|
|
222
|
+
raise ConfigurationError(diagnostic(
|
|
223
|
+
BIB_FILE_ABSOLUTE, file, "bib_files", "name",
|
|
224
|
+
f"'{name}' is an absolute path; a bib_files name is a name under "
|
|
225
|
+
"bib_dir, and it is emitted as the work's source.file, which is "
|
|
226
|
+
"never absolute"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def reject_name_outside_bib_dir(name, bib_dir, file: Optional[str] = None) -> None:
|
|
230
|
+
"""Raise when a configured `.bib` name does not stay under ``bib_dir``
|
|
231
|
+
(SPEC.md "Diagnostic codes"), located as `reject_absolute_name` locates.
|
|
232
|
+
|
|
233
|
+
The lexical check reads ``name`` by Windows rules on every host, so the
|
|
234
|
+
result does not depend on where it is compiled.
|
|
235
|
+
"""
|
|
236
|
+
if not isinstance(name, str) or "\0" in name:
|
|
237
|
+
return
|
|
238
|
+
parts = PureWindowsPath(name)
|
|
239
|
+
escapes = bool(parts.drive) or ".." in parts.parts
|
|
240
|
+
if not escapes:
|
|
241
|
+
root = Path(os.path.realpath(bib_dir))
|
|
242
|
+
# The path the readers open, built the way they build it: with an
|
|
243
|
+
# empty bib_dir it is rooted, not relative to the working directory.
|
|
244
|
+
target = Path(os.path.realpath(f"{bib_dir}/{name}"))
|
|
245
|
+
escapes = not target.is_relative_to(root)
|
|
246
|
+
if escapes:
|
|
247
|
+
raise ConfigurationError(diagnostic(
|
|
248
|
+
BIB_FILE_OUTSIDE, file, "bib_files", "name",
|
|
249
|
+
f"'{name}' is not under bib_dir '{bib_dir}'; a bib_files name is "
|
|
250
|
+
"a name under bib_dir, with no '..' component and no symlink "
|
|
251
|
+
"leading out of it"))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def json_lab(lab: dict, file: Optional[str] = None) -> dict:
|
|
255
|
+
"""``lab`` as the document carries it: a copy with every date and
|
|
256
|
+
timestamp as ISO 8601 text, or `VALUE_NOT_JSON` raised at the value."""
|
|
257
|
+
def plain(value, path):
|
|
258
|
+
def refuse(what):
|
|
259
|
+
raise ConfigurationError(diagnostic(
|
|
260
|
+
VALUE_NOT_JSON, file, "lab", path or None,
|
|
261
|
+
f"{f'lab.{path}' if path else 'lab'} {what}; a "
|
|
262
|
+
"value under lab is text, a number, a boolean, a date, or a "
|
|
263
|
+
"list or mapping of them with string keys"))
|
|
264
|
+
if isinstance(value, date):
|
|
265
|
+
return value.isoformat()
|
|
266
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
267
|
+
refuse(f"is {value!r}, which JSON cannot write")
|
|
268
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
269
|
+
return value
|
|
270
|
+
if isinstance(value, list):
|
|
271
|
+
return [plain(v, f"{path}[{i}]") for i, v in enumerate(value)]
|
|
272
|
+
if isinstance(value, dict):
|
|
273
|
+
for key in value:
|
|
274
|
+
if not isinstance(key, str):
|
|
275
|
+
refuse(f"has the key {key}, {_kind(key)}, which YAML and "
|
|
276
|
+
"JSON would write differently")
|
|
277
|
+
return {k: plain(v, f"{path}.{k}" if path else k)
|
|
278
|
+
for k, v in value.items()}
|
|
279
|
+
refuse(f"is {'binary' if isinstance(value, bytes) else _kind(value)}")
|
|
280
|
+
|
|
281
|
+
return plain(lab, "")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class BibFile:
|
|
286
|
+
"""A single BibTeX file and its category label.
|
|
287
|
+
|
|
288
|
+
``name`` is a name under ``bib_dir``. It is checked here and, because the
|
|
289
|
+
dataclass is mutable, again by `Work.to_dict()` (SPEC.md §1).
|
|
290
|
+
"""
|
|
291
|
+
name: str
|
|
292
|
+
category: str
|
|
293
|
+
|
|
294
|
+
def __post_init__(self):
|
|
295
|
+
reject_absolute_name(self.name)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class LabDataConfig:
|
|
300
|
+
"""Configuration for sslabdata, loadable from a `lab.yaml` (format:
|
|
301
|
+
README.md)."""
|
|
302
|
+
bib_dir: str
|
|
303
|
+
bib_files: List[BibFile]
|
|
304
|
+
pdf_base_url: Optional[str] = None
|
|
305
|
+
people_file: Optional[str] = None
|
|
306
|
+
projects_file: Optional[str] = None
|
|
307
|
+
lab: Optional[Dict[str, object]] = None
|
|
308
|
+
|
|
309
|
+
# Where this configuration was read from, so a diagnostic about it can
|
|
310
|
+
# name the file the user would edit. Never emitted.
|
|
311
|
+
path: Optional[str] = None
|
|
312
|
+
|
|
313
|
+
# After `path`, so the fields above keep their positions in the
|
|
314
|
+
# constructor.
|
|
315
|
+
collaborators_file: Optional[str] = None
|
|
316
|
+
|
|
317
|
+
# Keys `lab.yaml` held that sslabdata does not read, for the assembler to
|
|
318
|
+
# report. Named as text, because a YAML key need not be a string (`7:`).
|
|
319
|
+
# Never emitted.
|
|
320
|
+
unknown_keys: List[str] = field(default_factory=list)
|
|
321
|
+
|
|
322
|
+
# A `CONTROL_CHARACTER` diagnostic for each value of `lab.yaml` its
|
|
323
|
+
# control characters were removed from, for the assembler to report.
|
|
324
|
+
# Never emitted.
|
|
325
|
+
control_characters: List[Diagnostic] = field(default_factory=list)
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def from_yaml(cls, path: str) -> 'LabDataConfig':
|
|
329
|
+
"""Load configuration from a YAML file."""
|
|
330
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
331
|
+
data, repeated, controls = read_yaml(f)
|
|
332
|
+
|
|
333
|
+
def reject(code: str, key: Optional[str], field_name: Optional[str],
|
|
334
|
+
message: str) -> NoReturn:
|
|
335
|
+
raise ConfigurationError(
|
|
336
|
+
diagnostic(code, str(path), key, field_name, message))
|
|
337
|
+
|
|
338
|
+
# The first repeat is reported, as the first of any other fault is.
|
|
339
|
+
for repeat in repeated:
|
|
340
|
+
reject(KEY_REPEATED, *_located((*repeat.path, repeat.key)),
|
|
341
|
+
repeated_message(repeat))
|
|
342
|
+
|
|
343
|
+
if not isinstance(data, dict):
|
|
344
|
+
reject(NOT_A_MAPPING, None, None,
|
|
345
|
+
f"the configuration is {_kind(data)}, not a mapping of keys")
|
|
346
|
+
if data.get('bib_dir') is None:
|
|
347
|
+
reject(KEY_MISSING, 'bib_dir', None,
|
|
348
|
+
"the required key bib_dir is missing")
|
|
349
|
+
if not isinstance(data['bib_dir'], str):
|
|
350
|
+
reject(TYPE_INVALID, 'bib_dir', None,
|
|
351
|
+
f"bib_dir is {_kind(data['bib_dir'])}; it must be a string")
|
|
352
|
+
if data.get('lab') is not None and not isinstance(data['lab'], dict):
|
|
353
|
+
reject(TYPE_INVALID, 'lab', None,
|
|
354
|
+
f"lab is {_kind(data['lab'])}; it must be a mapping")
|
|
355
|
+
for key, expected in _LAB_TYPES.items():
|
|
356
|
+
if key in (data.get('lab') or {}) and not isinstance(
|
|
357
|
+
data['lab'][key], expected):
|
|
358
|
+
reject(TYPE_INVALID, 'lab', key,
|
|
359
|
+
f"lab.{key} is {_kind(data['lab'][key])}; it must be "
|
|
360
|
+
f"{'a mapping' if expected is dict else 'a string'}")
|
|
361
|
+
lab = None if data.get('lab') is None else json_lab(data['lab'],
|
|
362
|
+
str(path))
|
|
363
|
+
for key in _STRING_KEYS:
|
|
364
|
+
if data.get(key) is not None and not isinstance(data[key], str):
|
|
365
|
+
reject(TYPE_INVALID, key, None,
|
|
366
|
+
f"{key} is {_kind(data[key])}; it must be a string")
|
|
367
|
+
entries = data.get('bib_files')
|
|
368
|
+
if entries is None:
|
|
369
|
+
entries = []
|
|
370
|
+
if not isinstance(entries, list):
|
|
371
|
+
reject(TYPE_INVALID, 'bib_files', None,
|
|
372
|
+
f"bib_files is {_kind(entries)}; it must be a list of "
|
|
373
|
+
"{name, category} mappings")
|
|
374
|
+
# An entry is a mapping of exactly a string `name` and a string
|
|
375
|
+
# `category`: both reach the document as strings (`work.source.file`
|
|
376
|
+
# and `work.category`), so neither is coerced.
|
|
377
|
+
for number, bf in enumerate(entries, start=1):
|
|
378
|
+
if not isinstance(bf, dict):
|
|
379
|
+
reject(TYPE_INVALID, 'bib_files', None,
|
|
380
|
+
f"bib_files entry {number} is {_kind(bf)}; it must be "
|
|
381
|
+
"a {name, category} mapping")
|
|
382
|
+
for key in bf:
|
|
383
|
+
if key not in ('name', 'category'):
|
|
384
|
+
reject(TYPE_INVALID, 'bib_files', str(key),
|
|
385
|
+
f"bib_files entry {number} has the key '{key}'; "
|
|
386
|
+
"an entry holds only name and category")
|
|
387
|
+
for required in ('name', 'category'):
|
|
388
|
+
if bf.get(required) is None:
|
|
389
|
+
reject(KEY_MISSING, 'bib_files', required,
|
|
390
|
+
f"bib_files entry {number} has no {required}")
|
|
391
|
+
if not isinstance(bf[required], str):
|
|
392
|
+
reject(TYPE_INVALID, 'bib_files', required,
|
|
393
|
+
f"bib_files entry {number} has a {required} that "
|
|
394
|
+
f"is {_kind(bf[required])}; it must be a string")
|
|
395
|
+
|
|
396
|
+
# Checked here as well as in `BibFile`, because here the file the
|
|
397
|
+
# user would edit is known and the diagnostic can name it.
|
|
398
|
+
for bf in entries:
|
|
399
|
+
reject_absolute_name(bf['name'], str(path))
|
|
400
|
+
for bf in entries:
|
|
401
|
+
reject_name_outside_bib_dir(bf['name'], data['bib_dir'], str(path))
|
|
402
|
+
|
|
403
|
+
bib_files = [BibFile(**bf) for bf in entries]
|
|
404
|
+
|
|
405
|
+
return cls(
|
|
406
|
+
bib_dir=data['bib_dir'],
|
|
407
|
+
bib_files=bib_files,
|
|
408
|
+
pdf_base_url=data.get('pdf_base_url'),
|
|
409
|
+
people_file=data.get('people_file'),
|
|
410
|
+
projects_file=data.get('projects_file'),
|
|
411
|
+
lab=lab,
|
|
412
|
+
path=str(path),
|
|
413
|
+
collaborators_file=data.get('collaborators_file'),
|
|
414
|
+
unknown_keys=[str(key) for key in data if key not in KNOWN_KEYS],
|
|
415
|
+
control_characters=[
|
|
416
|
+
diagnostic(CONTROL_CHARACTER, str(path), *_located(found.path),
|
|
417
|
+
control_message(found.found))
|
|
418
|
+
for found in controls],
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _located(steps) -> Tuple[Optional[str], Optional[str]]:
|
|
423
|
+
"""A path in `lab.yaml` as any key of the file is located: the top-level
|
|
424
|
+
key, then the path below it."""
|
|
425
|
+
top = steps[0] if steps and isinstance(steps[0], str) else None
|
|
426
|
+
return top, dotted(steps[1 if top else 0:]) or None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _kind(value) -> str:
|
|
430
|
+
"""What a YAML value is, in the words a diagnostic uses for it."""
|
|
431
|
+
if value is None:
|
|
432
|
+
return "empty"
|
|
433
|
+
if isinstance(value, bool):
|
|
434
|
+
return "a boolean"
|
|
435
|
+
if isinstance(value, (int, float)):
|
|
436
|
+
return "a number"
|
|
437
|
+
if isinstance(value, str):
|
|
438
|
+
return "a string"
|
|
439
|
+
if isinstance(value, list):
|
|
440
|
+
return "a list"
|
|
441
|
+
if isinstance(value, dict):
|
|
442
|
+
return "a mapping"
|
|
443
|
+
return f"a {type(value).__name__}"
|
sslabdata/diagnostics.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coded diagnostics, one line each (SPEC.md "Diagnostic codes").
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2024 Personal Robotics Laboratory, University of Washington
|
|
5
|
+
Author: Siddhartha Srinivasa
|
|
6
|
+
MIT License - see LICENSE file for details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Diagnostic:
|
|
15
|
+
"""One coded diagnostic: its parts, and the line they make (``str()``).
|
|
16
|
+
|
|
17
|
+
The parts are kept so that no caller has to split the line, which a
|
|
18
|
+
citation key containing a colon would defeat.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
code: str
|
|
22
|
+
file: Optional[str]
|
|
23
|
+
key: Optional[str]
|
|
24
|
+
field: Optional[str]
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
# A line break in the message -- a library's multi-line wording, or a
|
|
29
|
+
# name written across two lines -- becomes a space, so every line of
|
|
30
|
+
# output carries a code.
|
|
31
|
+
if self.code not in CLASSES:
|
|
32
|
+
raise ValueError(f"unregistered diagnostic code {self.code!r}")
|
|
33
|
+
object.__setattr__(self, "message",
|
|
34
|
+
" ".join(self.message.splitlines()))
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return (f"{self.code} {self.file or ''}:{self.key or ''}:"
|
|
38
|
+
f"{self.field or ''}: {self.message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def diagnostic(code: str, file: Optional[str], key: Optional[str],
|
|
42
|
+
field: Optional[str], message: str) -> Diagnostic:
|
|
43
|
+
"""Build one coded diagnostic; ``None`` parts are left empty in its line."""
|
|
44
|
+
return Diagnostic(code, file, key, field, message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The four classes a code can belong to (SPEC.md "Diagnostic codes").
|
|
48
|
+
FATAL_AT_LOAD = "fatal at load"
|
|
49
|
+
FATAL = "fatal"
|
|
50
|
+
VALIDATION_ERROR = "validation error"
|
|
51
|
+
WARNING = "warning"
|
|
52
|
+
|
|
53
|
+
# Every code in use, with its class. The registry under
|
|
54
|
+
# SPEC.md "Diagnostic codes" lists the same codes; a test holds the two
|
|
55
|
+
# together.
|
|
56
|
+
CLASSES: Dict[str, str] = {
|
|
57
|
+
"CONFIG-BIB-FILE-ABSOLUTE": FATAL_AT_LOAD,
|
|
58
|
+
"CONFIG-BIB-FILE-OUTSIDE-BIB-DIR": FATAL_AT_LOAD,
|
|
59
|
+
"CONFIG-NOT-A-MAPPING": FATAL_AT_LOAD,
|
|
60
|
+
"CONFIG-KEY-MISSING": FATAL_AT_LOAD,
|
|
61
|
+
"CONFIG-TYPE-INVALID": FATAL_AT_LOAD,
|
|
62
|
+
"CONFIG-VALUE-NOT-JSON": FATAL_AT_LOAD,
|
|
63
|
+
"CONFIG-KEY-REPEATED": FATAL_AT_LOAD,
|
|
64
|
+
"CONFIG-NOT-FOUND": FATAL_AT_LOAD,
|
|
65
|
+
"CONFIG-UNREADABLE": FATAL_AT_LOAD,
|
|
66
|
+
"BIB-CROSSREF-UNSUPPORTED": FATAL,
|
|
67
|
+
"CONFIG-FILE-NOT-FOUND": FATAL,
|
|
68
|
+
"CONFIG-PATH-WRONG-KIND": FATAL,
|
|
69
|
+
"PEOPLE-NOT-A-LIST": FATAL,
|
|
70
|
+
"PEOPLE-FIELD-MISSING": FATAL,
|
|
71
|
+
"PEOPLE-YAML-INVALID": FATAL,
|
|
72
|
+
"PROJECTS-YAML-INVALID": FATAL,
|
|
73
|
+
"PROJECTS-NOT-A-LIST": FATAL,
|
|
74
|
+
"PROJECTS-FIELD-MISSING": FATAL,
|
|
75
|
+
"COLLABORATORS-YAML-INVALID": FATAL,
|
|
76
|
+
"COLLABORATORS-NOT-A-LIST": FATAL,
|
|
77
|
+
"COLLABORATORS-FIELD-MISSING": FATAL,
|
|
78
|
+
"RECORD-KEY-REPEATED": FATAL,
|
|
79
|
+
"OUTPUT-WRITE-FAILED": FATAL,
|
|
80
|
+
"BIB-ENCODING-INVALID": FATAL,
|
|
81
|
+
"BIB-DUPLICATE-KEY": VALIDATION_ERROR,
|
|
82
|
+
"RESOLVE-PROJECT-UNKNOWN": VALIDATION_ERROR,
|
|
83
|
+
"PEOPLE-ID-DUPLICATE": VALIDATION_ERROR,
|
|
84
|
+
"PROJECTS-ID-DUPLICATE": VALIDATION_ERROR,
|
|
85
|
+
"BIB-YEAR-MISSING": WARNING,
|
|
86
|
+
"BIB-YEAR-INVALID": WARNING,
|
|
87
|
+
"BIB-DOI-INVALID": WARNING,
|
|
88
|
+
"BIB-OTHERS-NOT-LAST": WARNING,
|
|
89
|
+
"BIB-STRING-UNDEFINED": WARNING,
|
|
90
|
+
"BIB-STRING-REDEFINED": WARNING,
|
|
91
|
+
"BIB-SYNTAX-ERROR": WARNING,
|
|
92
|
+
"BIB-BRACE-MISMATCH": WARNING,
|
|
93
|
+
"BIB-VENUE-MISSING": WARNING,
|
|
94
|
+
"BIB-ENTRY-TYPE-UNSUPPORTED": WARNING,
|
|
95
|
+
"BIB-PARSER-MESSAGE": WARNING,
|
|
96
|
+
"BIB-WRITE-BACK-FAILED": WARNING,
|
|
97
|
+
"LATEX-COMMAND-UNKNOWN": WARNING,
|
|
98
|
+
"LATEX-CONVERSION-FAILED": WARNING,
|
|
99
|
+
"TEXT-CONTROL-CHARACTER": WARNING,
|
|
100
|
+
"ID-GROUPING-SPANS-SPELLINGS": WARNING,
|
|
101
|
+
"ID-GROUPING-INITIALS-AMBIGUOUS": WARNING,
|
|
102
|
+
"ID-GROUPING-AMBIGUOUS-DECLARED": WARNING,
|
|
103
|
+
"RESOLVE-AMBIGUOUS-NAME": WARNING,
|
|
104
|
+
"RESOLVE-SUGGESTION": WARNING,
|
|
105
|
+
"RESOLVE-UNRESOLVED-NAME": WARNING,
|
|
106
|
+
"RESOLVE-COLLABORATOR-ALIAS-IS-MEMBER": WARNING,
|
|
107
|
+
"PEOPLE-ALIAS-AMBIGUOUS": WARNING,
|
|
108
|
+
"PEOPLE-ROLE-INVALID": WARNING,
|
|
109
|
+
"PEOPLE-STATUS-INVALID": WARNING,
|
|
110
|
+
"PROJECTS-STATUS-INVALID": WARNING,
|
|
111
|
+
"CONFIG-LAB-NAME-MISSING": WARNING,
|
|
112
|
+
"CONFIG-KEY-UNKNOWN": WARNING,
|
|
113
|
+
"RECORD-KEY-UNKNOWN": WARNING,
|
|
114
|
+
"RECORD-TYPE-INVALID": WARNING,
|
|
115
|
+
"CONFIG-BIB-FILES-MISSING": WARNING,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# The codes `--strict` leaves as warnings. Each one's reason is in
|
|
119
|
+
# SPEC.md "Diagnostic codes".
|
|
120
|
+
NEVER_AN_ERROR = frozenset({
|
|
121
|
+
"BIB-STRING-REDEFINED",
|
|
122
|
+
"ID-GROUPING-SPANS-SPELLINGS",
|
|
123
|
+
"ID-GROUPING-INITIALS-AMBIGUOUS",
|
|
124
|
+
"ID-GROUPING-AMBIGUOUS-DECLARED",
|
|
125
|
+
"RESOLVE-UNRESOLVED-NAME",
|
|
126
|
+
"RESOLVE-SUGGESTION",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
# Severities as a run reports them.
|
|
130
|
+
ERROR, WARN = "error", "warning"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def severity(line: Diagnostic, validating: bool, strict: bool) -> str:
|
|
134
|
+
"""`ERROR` or `WARN` for one diagnostic in one run.
|
|
135
|
+
|
|
136
|
+
The rule is SPEC.md "Diagnostic codes".
|
|
137
|
+
"""
|
|
138
|
+
code = line.code
|
|
139
|
+
kind = CLASSES[code]
|
|
140
|
+
if kind in (FATAL_AT_LOAD, FATAL):
|
|
141
|
+
return ERROR
|
|
142
|
+
if kind == VALIDATION_ERROR and validating:
|
|
143
|
+
return ERROR
|
|
144
|
+
if strict and code not in NEVER_AN_ERROR:
|
|
145
|
+
return ERROR
|
|
146
|
+
return WARN
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def in_report_order(lines: List[Diagnostic]) -> List[Diagnostic]:
|
|
150
|
+
"""The diagnostics in report order: by class, stable within one."""
|
|
151
|
+
order = (FATAL_AT_LOAD, FATAL, VALIDATION_ERROR, WARNING)
|
|
152
|
+
return sorted(lines, key=lambda line: order.index(CLASSES[line.code]))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def record(line: Diagnostic, level: str) -> Dict[str, Optional[str]]:
|
|
156
|
+
"""One diagnostic as a JSON record (SPEC.md "Diagnostics as JSON")."""
|
|
157
|
+
return {"code": line.code, "severity": level,
|
|
158
|
+
"file": line.file or None, "key": line.key or None,
|
|
159
|
+
"field": line.field or None, "message": line.message}
|
sslabdata/exporters.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export utilities for sslabdata.
|
|
3
|
+
|
|
4
|
+
Serializes LabData to YAML or JSON files.
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2024 Personal Robotics Laboratory, University of Washington
|
|
7
|
+
Author: Siddhartha Srinivasa
|
|
8
|
+
MIT License - see LICENSE file for details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import uuid
|
|
15
|
+
import yaml
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .models import LabData
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _write(output_path: str, text: str) -> None:
|
|
22
|
+
"""Write ``text`` atomically over ``output_path`` (SPEC.md §1).
|
|
23
|
+
|
|
24
|
+
Takes text, not a `LabData`, so a document sslabdata refuses to serialize
|
|
25
|
+
is refused before any file is created.
|
|
26
|
+
"""
|
|
27
|
+
output_file = Path(output_path)
|
|
28
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
# The temporary file is never more permissive than the file it replaces,
|
|
30
|
+
# and its name does not depend on the destination's, which may already be
|
|
31
|
+
# as long as a name can be.
|
|
32
|
+
try:
|
|
33
|
+
mode = stat.S_IMODE(output_file.stat().st_mode)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
mode = None
|
|
36
|
+
temp = output_file.parent / f".{uuid.uuid4().hex}.tmp"
|
|
37
|
+
try:
|
|
38
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
39
|
+
0o666 if mode is None else mode)
|
|
40
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
41
|
+
if mode is not None:
|
|
42
|
+
os.chmod(temp, mode)
|
|
43
|
+
f.write(text)
|
|
44
|
+
f.flush()
|
|
45
|
+
os.fsync(f.fileno())
|
|
46
|
+
os.replace(temp, output_file)
|
|
47
|
+
except BaseException:
|
|
48
|
+
temp.unlink(missing_ok=True)
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def serialize(data: LabData, fmt: str, indent: int = 2) -> str:
|
|
53
|
+
"""The document as text in ``fmt``, ``yaml`` or ``json``, written nowhere.
|
|
54
|
+
|
|
55
|
+
Both exporters and ``--validate`` build the text here, so a document
|
|
56
|
+
``--validate`` passes is one ``--output`` can serialize. `LabData.to_dict()`
|
|
57
|
+
refuses what the document cannot carry; `safe_dump` and `allow_nan=False`
|
|
58
|
+
are a backstop behind it.
|
|
59
|
+
"""
|
|
60
|
+
tree = data.to_dict()
|
|
61
|
+
if fmt == 'json':
|
|
62
|
+
return json.dumps(tree, indent=indent, ensure_ascii=False,
|
|
63
|
+
allow_nan=False)
|
|
64
|
+
return yaml.safe_dump(tree, default_flow_style=False, allow_unicode=True,
|
|
65
|
+
sort_keys=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export_to_yaml(data: LabData, output_path: str):
|
|
69
|
+
"""Export LabData to a YAML file.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
data: Assembled LabData instance
|
|
73
|
+
output_path: Path to output YAML file
|
|
74
|
+
"""
|
|
75
|
+
_write(output_path, serialize(data, 'yaml'))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def export_to_json(data: LabData, output_path: str, indent: int = 2):
|
|
79
|
+
"""Export LabData to a JSON file.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
data: Assembled LabData instance
|
|
83
|
+
output_path: Path to output JSON file
|
|
84
|
+
indent: Indentation level for pretty printing
|
|
85
|
+
"""
|
|
86
|
+
_write(output_path, serialize(data, 'json', indent))
|