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/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for sslabdata.
|
|
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
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .config import ConfigurationError, LabDataConfig
|
|
19
|
+
from .assembler import assemble_result, unresolved_name_diagnostics
|
|
20
|
+
from .diagnostics import (
|
|
21
|
+
ERROR, Diagnostic, diagnostic, in_report_order, record, severity,
|
|
22
|
+
)
|
|
23
|
+
from .exporters import export_to_yaml, export_to_json, serialize
|
|
24
|
+
|
|
25
|
+
CONFIG_NOT_FOUND = "CONFIG-NOT-FOUND"
|
|
26
|
+
CONFIG_UNREADABLE = "CONFIG-UNREADABLE"
|
|
27
|
+
|
|
28
|
+
# What reading a configuration can fail with for reasons of the input, not of
|
|
29
|
+
# the program: a file that cannot be opened, is not UTF-8 or is not YAML. Any
|
|
30
|
+
# other exception is a defect and is left to propagate.
|
|
31
|
+
CONFIG_READ_ERRORS = (OSError, UnicodeDecodeError, yaml.YAMLError)
|
|
32
|
+
|
|
33
|
+
OUTPUT_WRITE_FAILED = "OUTPUT-WRITE-FAILED"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None):
|
|
37
|
+
"""Main CLI entry point. ``argv`` defaults to ``sys.argv[1:]``."""
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
if not args.output and not args.validate and not args.unresolved:
|
|
42
|
+
parser.error("One of --output, --validate, or --unresolved is required")
|
|
43
|
+
|
|
44
|
+
# --format has two meanings (SPEC.md §1).
|
|
45
|
+
as_json = args.format == 'json' and (args.validate or args.unresolved)
|
|
46
|
+
|
|
47
|
+
config, result = load(args.config, as_json)
|
|
48
|
+
found = result.diagnostics
|
|
49
|
+
|
|
50
|
+
# Serialized as --output would, so --validate cannot pass a document
|
|
51
|
+
# --output refuses.
|
|
52
|
+
if args.validate:
|
|
53
|
+
try:
|
|
54
|
+
serialize(result.data, args.format)
|
|
55
|
+
except ConfigurationError as e:
|
|
56
|
+
found = in_report_order([*found, located(e, args.config)])
|
|
57
|
+
|
|
58
|
+
def level(line):
|
|
59
|
+
return severity(line, validating=args.validate, strict=args.strict)
|
|
60
|
+
errors = [line for line in found if level(line) == ERROR]
|
|
61
|
+
warnings = [line for line in found if level(line) != ERROR]
|
|
62
|
+
|
|
63
|
+
if as_json:
|
|
64
|
+
status = report_json(found, level, errors, config, result,
|
|
65
|
+
args.unresolved and not args.validate)
|
|
66
|
+
elif args.validate:
|
|
67
|
+
status = report_validation(result, errors, warnings)
|
|
68
|
+
else:
|
|
69
|
+
status = report_to_stderr(errors, warnings)
|
|
70
|
+
if not status and args.unresolved:
|
|
71
|
+
status = report_unresolved(config, result)
|
|
72
|
+
elif not status:
|
|
73
|
+
status = write_output(result.data, args)
|
|
74
|
+
if status:
|
|
75
|
+
sys.exit(status)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
79
|
+
"""The command line's options, help and examples."""
|
|
80
|
+
parser = argparse.ArgumentParser(
|
|
81
|
+
description='Assemble academic lab data from BibTeX and YAML',
|
|
82
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
83
|
+
epilog="""
|
|
84
|
+
Examples:
|
|
85
|
+
# Generate YAML output
|
|
86
|
+
sslabdata --config lab.yaml --output lab.yml
|
|
87
|
+
|
|
88
|
+
# Generate JSON output
|
|
89
|
+
sslabdata --config lab.yaml --format json --output lab.json
|
|
90
|
+
|
|
91
|
+
# Validate configuration and data
|
|
92
|
+
sslabdata --config lab.yaml --validate
|
|
93
|
+
|
|
94
|
+
# Show unresolved author names
|
|
95
|
+
sslabdata --config lab.yaml --unresolved
|
|
96
|
+
|
|
97
|
+
# Fail on every coded diagnostic that can be an error, as JSON records
|
|
98
|
+
sslabdata --config lab.yaml --validate --strict --format json
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
'--config', required=True,
|
|
104
|
+
help='Path to YAML configuration file (lab.yaml)'
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
'--format', choices=['yaml', 'json'], default='yaml',
|
|
108
|
+
help='Output format (default: yaml). With --output, the document; '
|
|
109
|
+
'with --validate or --unresolved, json prints the diagnostics '
|
|
110
|
+
'as one JSON array'
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
'--output',
|
|
114
|
+
help='Output file path'
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
'--validate', action='store_true',
|
|
118
|
+
help='Validate configuration and report issues, then exit'
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
'--unresolved', action='store_true',
|
|
122
|
+
help='Show unresolved author names, then exit'
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
'--strict', action='store_true',
|
|
126
|
+
help='Treat every coded diagnostic as an error, except those about '
|
|
127
|
+
'authors who matched no lab member and redefined @string macros'
|
|
128
|
+
)
|
|
129
|
+
return parser
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load(path: str, as_json: bool):
|
|
133
|
+
"""Load the configuration at ``path`` and assemble it, as
|
|
134
|
+
``(config, result)``; a load failure is reported and exits 1."""
|
|
135
|
+
try:
|
|
136
|
+
config = LabDataConfig.from_yaml(path)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
stop(diagnostic(CONFIG_NOT_FOUND, path, None, None,
|
|
139
|
+
"configuration file not found"), "Error: ", as_json)
|
|
140
|
+
except ConfigurationError as e:
|
|
141
|
+
stop(e.args[0], "Error loading configuration: ", as_json)
|
|
142
|
+
except CONFIG_READ_ERRORS as e:
|
|
143
|
+
stop(diagnostic(CONFIG_UNREADABLE, path, None, None, str(e)),
|
|
144
|
+
"Error loading configuration: ", as_json)
|
|
145
|
+
|
|
146
|
+
# An input file that exists but cannot be read (permissions, say) is the
|
|
147
|
+
# one failure the loaders leave for here; its message names the file.
|
|
148
|
+
try:
|
|
149
|
+
result = assemble_result(config)
|
|
150
|
+
except OSError as e:
|
|
151
|
+
stop(diagnostic(CONFIG_UNREADABLE, path, None, None, str(e)),
|
|
152
|
+
"Error loading configuration: ", as_json)
|
|
153
|
+
return config, result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def report_json(found, level, errors, config, result,
|
|
157
|
+
with_unresolved: bool) -> int:
|
|
158
|
+
"""Print the diagnostics as one JSON array on standard output, with the
|
|
159
|
+
unresolved names too when ``with_unresolved``; 1 if any is an error."""
|
|
160
|
+
records = [record(line, level(line)) for line in found]
|
|
161
|
+
if with_unresolved:
|
|
162
|
+
records += [record(line, level(line)) for line in
|
|
163
|
+
unresolved_name_diagnostics(result.data.works,
|
|
164
|
+
result.unresolved_authors,
|
|
165
|
+
config.bib_dir)]
|
|
166
|
+
print(json.dumps(records, indent=2, ensure_ascii=False))
|
|
167
|
+
return 1 if errors else 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def report_validation(result, errors, warnings) -> int:
|
|
171
|
+
"""Print the --validate report on standard output; 1 if any error."""
|
|
172
|
+
data = result.data
|
|
173
|
+
print(f"Works: {len(data.works)}")
|
|
174
|
+
print(f"People: {len(data.people)}")
|
|
175
|
+
print(f"Projects: {len(data.projects)}")
|
|
176
|
+
|
|
177
|
+
if result.unresolved_authors:
|
|
178
|
+
print(f"\nUnresolved authors ({len(result.unresolved_authors)}):")
|
|
179
|
+
for name in sorted(result.unresolved_authors):
|
|
180
|
+
print(f" - {name}")
|
|
181
|
+
|
|
182
|
+
if warnings:
|
|
183
|
+
print(f"\nWarnings ({len(warnings)}):")
|
|
184
|
+
for warning in warnings:
|
|
185
|
+
print(f" - {warning}")
|
|
186
|
+
|
|
187
|
+
if errors:
|
|
188
|
+
print(f"\nBibliography errors ({len(errors)}):")
|
|
189
|
+
for error in errors:
|
|
190
|
+
print(f" - {error}")
|
|
191
|
+
print(f"\nValidation found {len(errors)} error(s).")
|
|
192
|
+
return 1
|
|
193
|
+
print("\nValidation passed.")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def report_to_stderr(errors, warnings) -> int:
|
|
198
|
+
"""Print the diagnostics on standard error; 1 if any is an error.
|
|
199
|
+
|
|
200
|
+
An error stops the run outside --validate too, so skipping validation
|
|
201
|
+
cannot produce a document.
|
|
202
|
+
"""
|
|
203
|
+
for error in errors:
|
|
204
|
+
print(error, file=sys.stderr)
|
|
205
|
+
for message in warnings:
|
|
206
|
+
print(f"Warning: {message}", file=sys.stderr)
|
|
207
|
+
return 1 if errors else 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def report_unresolved(config, result) -> int:
|
|
211
|
+
"""Print the --unresolved report on standard output."""
|
|
212
|
+
if not config.people_file:
|
|
213
|
+
print("Author resolution is not configured (no people_file).")
|
|
214
|
+
return 0
|
|
215
|
+
if not result.unresolved_authors:
|
|
216
|
+
print("All authors resolved.")
|
|
217
|
+
else:
|
|
218
|
+
print(f"Unresolved authors ({len(result.unresolved_authors)}):")
|
|
219
|
+
for name in sorted(result.unresolved_authors):
|
|
220
|
+
print(f" {name}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def write_output(data, args) -> int:
|
|
225
|
+
"""Write the document to --output in --format; 1 if it could not be.
|
|
226
|
+
|
|
227
|
+
An `OSError` names the path it refused only when that is not beside
|
|
228
|
+
--output: a name beside it is the random temporary file, which means
|
|
229
|
+
nothing to the user.
|
|
230
|
+
"""
|
|
231
|
+
export_func = export_to_yaml if args.format == 'yaml' else export_to_json
|
|
232
|
+
try:
|
|
233
|
+
export_func(data, args.output)
|
|
234
|
+
except ConfigurationError as e:
|
|
235
|
+
# What serializing refuses, reported as --validate reports it.
|
|
236
|
+
print(located(e, args.config), file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
except OSError as e:
|
|
239
|
+
reason = e.strerror or str(e)
|
|
240
|
+
if e.filename and (Path(os.fsdecode(e.filename)).parent
|
|
241
|
+
!= Path(args.output).parent):
|
|
242
|
+
reason += f": '{os.fsdecode(e.filename)}'"
|
|
243
|
+
print(diagnostic(OUTPUT_WRITE_FAILED, args.output, None, None,
|
|
244
|
+
f"the document could not be written: {reason}"),
|
|
245
|
+
file=sys.stderr)
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
print(f"Wrote {args.output}")
|
|
249
|
+
print(f" {len(data.works)} works, "
|
|
250
|
+
f"{len(data.people)} people, "
|
|
251
|
+
f"{len(data.projects)} projects")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def located(error: ConfigurationError, config: str) -> Diagnostic:
|
|
256
|
+
"""The diagnostic a document refused while it was serialized carries,
|
|
257
|
+
located at ``config`` when it names no file: `LabData.to_dict()` cannot
|
|
258
|
+
know which file its values came from."""
|
|
259
|
+
line = error.args[0]
|
|
260
|
+
return line if line.file else replace(line, file=config)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def stop(line, prefix: str, as_json: bool) -> None:
|
|
264
|
+
"""Report a configuration that did not load, and exit 1: as text on
|
|
265
|
+
standard error after ``prefix``, or as a one-record JSON array."""
|
|
266
|
+
if as_json:
|
|
267
|
+
print(json.dumps([record(line, ERROR)], indent=2, ensure_ascii=False))
|
|
268
|
+
else:
|
|
269
|
+
print(f"{prefix}{line}", file=sys.stderr)
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|