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/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()