divejson 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.
- divejson/__init__.py +64 -0
- divejson/_schema/1.0/divejson.schema.json +450 -0
- divejson/cli.py +281 -0
- divejson/conform.py +450 -0
- divejson/py.typed +0 -0
- divejson/uddf.py +1361 -0
- divejson/validate.py +410 -0
- divejson-0.2.0.dist-info/METADATA +119 -0
- divejson-0.2.0.dist-info/RECORD +12 -0
- divejson-0.2.0.dist-info/WHEEL +4 -0
- divejson-0.2.0.dist-info/entry_points.txt +2 -0
- divejson-0.2.0.dist-info/licenses/LICENSE +21 -0
divejson/cli.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""The ``divejson`` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .conform import Result, known_formats
|
|
13
|
+
from .conform import run as run_conform
|
|
14
|
+
from .uddf import NonConformingOutputError, UddfError, convert_uddf
|
|
15
|
+
from .validate import DuplicateMemberError, parse_document, validate_document
|
|
16
|
+
|
|
17
|
+
# How many source locations one grouped finding names before it stops listing them. A
|
|
18
|
+
# habit of a whole file - eight dives with no UTC offset - is one finding, and the point
|
|
19
|
+
# of the line is the finding rather than the roll call.
|
|
20
|
+
_WHERES_SHOWN = 3
|
|
21
|
+
|
|
22
|
+
# The collections a converted document can carry, with how to count them.
|
|
23
|
+
_COUNTED = (("dives", "dive", "dives"), ("trips", "trip", "trips"), ("sites", "site", "sites"), ("gear", "gear item", "gear items"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="divejson",
|
|
29
|
+
description="Tools for DiveJSON, an open dive-log interchange format.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"divejson {__version__}")
|
|
32
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
validate = commands.add_parser(
|
|
35
|
+
"validate",
|
|
36
|
+
help="validate documents against the DiveJSON spec",
|
|
37
|
+
description=(
|
|
38
|
+
"Validates each file against the DiveJSON JSON Schema and the semantic "
|
|
39
|
+
"requirements the schema cannot express. Exits non-zero if any file fails."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
validate.add_argument("files", nargs="+", type=Path, help="DiveJSON documents")
|
|
43
|
+
|
|
44
|
+
convert = commands.add_parser(
|
|
45
|
+
"convert",
|
|
46
|
+
help="convert UDDF dive logs into DiveJSON",
|
|
47
|
+
description=(
|
|
48
|
+
"Reads each UDDF file and writes a DiveJSON document beside it, named after "
|
|
49
|
+
"the input with a .divejson extension. Nothing the source did not record is "
|
|
50
|
+
"filled in, and everything it did not carry is reported: those lines are the "
|
|
51
|
+
"other half of the output, not a diagnostic. Exits non-zero if any file "
|
|
52
|
+
"could not be converted."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
convert.add_argument("files", nargs="+", type=Path, help="UDDF documents")
|
|
56
|
+
convert.add_argument(
|
|
57
|
+
"-o",
|
|
58
|
+
"--output",
|
|
59
|
+
type=Path,
|
|
60
|
+
help="write the document here instead of beside the input; only with one input file",
|
|
61
|
+
)
|
|
62
|
+
convert.add_argument("-f", "--force", action="store_true", help="overwrite an existing output file")
|
|
63
|
+
convert.add_argument(
|
|
64
|
+
"--exported-at",
|
|
65
|
+
type=_offset_aware,
|
|
66
|
+
help="the document's exported_at, as an offset-aware date-time; defaults to now",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
conform = commands.add_parser(
|
|
70
|
+
"conform",
|
|
71
|
+
help="run a conformance corpus against this implementation",
|
|
72
|
+
description=(
|
|
73
|
+
"Walks a conformance corpus: valid/ documents that must validate, invalid/ "
|
|
74
|
+
"ones that must not, one directory of reader pairs per source format named "
|
|
75
|
+
"for the format's id, and write/<format>/ of writer pairs. Exit status 0 if "
|
|
76
|
+
"every case passed, 1 if a case failed, and 2 if the corpus's shape is wrong "
|
|
77
|
+
"— an empty directory, a pair missing one of its halves, or pairs for a "
|
|
78
|
+
"format this implementation does not register, all of which mean cases that "
|
|
79
|
+
"never ran rather than cases that failed."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
conform.add_argument("corpus", type=Path, help="the corpus directory")
|
|
83
|
+
conform.add_argument(
|
|
84
|
+
"--strict",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="a format this implementation registers and the corpus has no pairs for is an error, not a warning",
|
|
87
|
+
)
|
|
88
|
+
conform.add_argument(
|
|
89
|
+
"--only",
|
|
90
|
+
action="append",
|
|
91
|
+
default=[],
|
|
92
|
+
metavar="FORMAT",
|
|
93
|
+
help="check only this format's pairs; repeatable. valid/ and invalid/ run regardless",
|
|
94
|
+
)
|
|
95
|
+
conform.add_argument(
|
|
96
|
+
"--skip",
|
|
97
|
+
action="append",
|
|
98
|
+
default=[],
|
|
99
|
+
metavar="FORMAT",
|
|
100
|
+
help="skip this format's pairs; repeatable, and only a format this implementation registers",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
args = parser.parse_args(argv)
|
|
104
|
+
if args.command == "convert":
|
|
105
|
+
return _convert_command(args.files, args.output, force=args.force, exported_at=args.exported_at)
|
|
106
|
+
if args.command == "conform":
|
|
107
|
+
return _conform_command(args.corpus, strict=args.strict, only=args.only, skip=args.skip)
|
|
108
|
+
return _validate_command(args.files)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _offset_aware(text: str) -> datetime:
|
|
112
|
+
"""Parse `--exported-at`, which the format requires to carry a UTC offset (spec §5.2).
|
|
113
|
+
|
|
114
|
+
It is one of the two members a converted document asserts about its own run rather than
|
|
115
|
+
about the source — `generator` is the other — and the one of those that moves every
|
|
116
|
+
time. Being able to pin it is what makes two conversions of one input diffable, and
|
|
117
|
+
what lets this repository's own fixture expectations be regenerated without every one
|
|
118
|
+
of them churning a line that carries no information about the change.
|
|
119
|
+
"""
|
|
120
|
+
normalized = text[:-1] + "+00:00" if text.endswith(("Z", "z")) else text
|
|
121
|
+
try:
|
|
122
|
+
value = datetime.fromisoformat(normalized)
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a date and time") from None
|
|
125
|
+
if value.utcoffset() is None:
|
|
126
|
+
raise argparse.ArgumentTypeError(f"{text!r} carries no UTC offset, which exported_at requires (spec §5.2)")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate_command(files: list[Path]) -> int:
|
|
131
|
+
failed = False
|
|
132
|
+
for path in files:
|
|
133
|
+
try:
|
|
134
|
+
text = path.read_text(encoding="utf-8")
|
|
135
|
+
document = parse_document(text)
|
|
136
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
137
|
+
print(f"{path}: unreadable — {error}")
|
|
138
|
+
failed = True
|
|
139
|
+
continue
|
|
140
|
+
except DuplicateMemberError as error:
|
|
141
|
+
print(f"{path}: 1 error")
|
|
142
|
+
print(f" $: {error}")
|
|
143
|
+
failed = True
|
|
144
|
+
continue
|
|
145
|
+
except json.JSONDecodeError as error:
|
|
146
|
+
print(f"{path}: 1 error")
|
|
147
|
+
print(f" $: not valid JSON — {error}")
|
|
148
|
+
failed = True
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
issues = validate_document(document, raw=text)
|
|
152
|
+
if issues:
|
|
153
|
+
failed = True
|
|
154
|
+
print(f"{path}: {len(issues)} error{'s' if len(issues) != 1 else ''}")
|
|
155
|
+
for issue in issues:
|
|
156
|
+
print(f" {issue}")
|
|
157
|
+
else:
|
|
158
|
+
print(f"{path}: OK")
|
|
159
|
+
return 1 if failed else 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _convert_command(
|
|
163
|
+
files: list[Path], output: Path | None, *, force: bool, exported_at: datetime | None = None
|
|
164
|
+
) -> int:
|
|
165
|
+
"""Convert each UDDF file, writing the document to a file and the report to stdout.
|
|
166
|
+
|
|
167
|
+
**The document goes to a file and never to stdout**, which is why there is no `-`
|
|
168
|
+
destination. The report is the half of this command's output a diver has to read, and
|
|
169
|
+
a converter that streamed the document down the same pipe would either bury it or
|
|
170
|
+
force the report onto stderr, where nobody looks. `--output` names the file instead;
|
|
171
|
+
an existing one is refused rather than silently replaced, since the obvious mistake is
|
|
172
|
+
converting into a hand-written document's name.
|
|
173
|
+
"""
|
|
174
|
+
if output is not None and len(files) > 1:
|
|
175
|
+
print(f"--output names one file, but {len(files)} were given")
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
failed = False
|
|
179
|
+
for path in files:
|
|
180
|
+
destination = output if output is not None else path.with_suffix(".divejson")
|
|
181
|
+
if destination == path:
|
|
182
|
+
print(f"{path}: the output would overwrite the input; pass --output to name another file")
|
|
183
|
+
failed = True
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
data = path.read_bytes()
|
|
187
|
+
except OSError as error:
|
|
188
|
+
print(f"{path}: unreadable — {error}")
|
|
189
|
+
failed = True
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
conversion = convert_uddf(data, exported_at=exported_at)
|
|
193
|
+
except NonConformingOutputError as error:
|
|
194
|
+
# Not a property of the file: every way a source can be wrong is meant to
|
|
195
|
+
# resolve to an omission and a note, so reaching here is this converter's bug.
|
|
196
|
+
print(f"{path}: the converter produced a document that does not conform, which is a bug in it")
|
|
197
|
+
for issue in error.issues:
|
|
198
|
+
print(f" {issue}")
|
|
199
|
+
failed = True
|
|
200
|
+
continue
|
|
201
|
+
except UddfError as error:
|
|
202
|
+
print(f"{path}: {error}")
|
|
203
|
+
failed = True
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if destination.exists() and not force:
|
|
207
|
+
print(f"{path}: {destination} already exists — pass --force to replace it, or --output to write elsewhere")
|
|
208
|
+
failed = True
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
destination.write_text(json.dumps(conversion.document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
212
|
+
except OSError as error:
|
|
213
|
+
print(f"{path}: could not write {destination} — {error}")
|
|
214
|
+
failed = True
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
print(f"{path}: {_counted(conversion.document)} → {destination}")
|
|
218
|
+
for message, wheres in conversion.grouped():
|
|
219
|
+
print(f" {_listed(wheres)}: {message}")
|
|
220
|
+
return 1 if failed else 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _conform_command(corpus: Path, *, strict: bool, only: list[str], skip: list[str]) -> int:
|
|
224
|
+
"""Walk a conformance corpus and print what it found.
|
|
225
|
+
|
|
226
|
+
A format named to `--only` or `--skip` that this implementation does not register is
|
|
227
|
+
refused rather than ignored. Ignoring it would let `--skip <format>` answer a corpus
|
|
228
|
+
the implementation cannot run, which is the one thing status 2 exists to say out
|
|
229
|
+
loud, and a typo would quietly check nothing.
|
|
230
|
+
"""
|
|
231
|
+
registered = known_formats()
|
|
232
|
+
unknown = sorted({*only, *skip} - registered)
|
|
233
|
+
if unknown:
|
|
234
|
+
print(f"no such format: {', '.join(unknown)}")
|
|
235
|
+
print(f" this implementation registers {', '.join(sorted(registered))}")
|
|
236
|
+
return 2
|
|
237
|
+
|
|
238
|
+
result = run_conform(corpus, strict=strict, only=only, skip=skip)
|
|
239
|
+
for group in result.groups:
|
|
240
|
+
failing = "none failing" if not group.failed else f"{group.failed} failing"
|
|
241
|
+
print(f"{group.name}: {_some(group.checked, group.noun)} checked, {failing}")
|
|
242
|
+
for finding in (*result.failures, *result.shape, *result.warnings):
|
|
243
|
+
print(finding)
|
|
244
|
+
for line in finding.detail:
|
|
245
|
+
print(f" {line}")
|
|
246
|
+
print(_conform_summary(result))
|
|
247
|
+
return result.status
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _conform_summary(result: Result) -> str:
|
|
251
|
+
parts = [f"{_some(result.checked, 'case')} checked"]
|
|
252
|
+
if result.failures:
|
|
253
|
+
parts.append(_some(len(result.failures), "failure"))
|
|
254
|
+
if result.shape:
|
|
255
|
+
parts.append(_some(len(result.shape), "corpus-shape error"))
|
|
256
|
+
if result.warnings:
|
|
257
|
+
parts.append(_some(len(result.warnings), "warning"))
|
|
258
|
+
return ", ".join(parts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _some(count: int, thing: str) -> str:
|
|
262
|
+
return f"{count} {thing}{'' if count == 1 else 's'}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _counted(document: dict) -> str:
|
|
266
|
+
parts = []
|
|
267
|
+
for member, singular, plural in _COUNTED:
|
|
268
|
+
rows = document.get(member) or []
|
|
269
|
+
if rows:
|
|
270
|
+
parts.append(f"{len(rows)} {singular if len(rows) == 1 else plural}")
|
|
271
|
+
return ", ".join(parts) if parts else "nothing the format carries"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _listed(wheres: list[str]) -> str:
|
|
275
|
+
if len(wheres) <= _WHERES_SHOWN:
|
|
276
|
+
return ", ".join(wheres)
|
|
277
|
+
return f"{', '.join(wheres[:_WHERES_SHOWN])} and {len(wheres) - _WHERES_SHOWN} more"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
sys.exit(main())
|