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/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__}"
@@ -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))