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/validate.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Validation of DiveJSON documents.
|
|
2
|
+
|
|
3
|
+
Two passes, mirroring §3 of the specification: the JSON Schema (types, required members,
|
|
4
|
+
enums, ranges, lengths, and the structural rules like Position objects), then the
|
|
5
|
+
semantic requirements the schema cannot express — identifier uniqueness, referential
|
|
6
|
+
closure, cross-member arithmetic, profile-series integrity and span, the offset
|
|
7
|
+
requirement on ``exported_at``, and the member-order rule, checked on the parsed
|
|
8
|
+
document's key order (which JSON parsing preserves).
|
|
9
|
+
|
|
10
|
+
Null is not a spelling of absence in this format (spec §5.4): the schema rejects it, so
|
|
11
|
+
the semantic checks below simply treat a missing member as missing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
24
|
+
from jsonschema.exceptions import best_match
|
|
25
|
+
|
|
26
|
+
from . import SPEC_VERSION
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Issue:
|
|
31
|
+
path: str
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
return f"{self.path or '$'}: {self.message}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DuplicateMemberError(ValueError):
|
|
39
|
+
"""A JSON object in the input carries the same member name twice (spec §9)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
43
|
+
obj: dict[str, Any] = {}
|
|
44
|
+
for key, value in pairs:
|
|
45
|
+
if key in obj:
|
|
46
|
+
raise DuplicateMemberError(f"duplicate member name {key!r}")
|
|
47
|
+
obj[key] = value
|
|
48
|
+
return obj
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_document(text: str) -> Any:
|
|
52
|
+
"""Parse document text as JSON, rejecting duplicate member names."""
|
|
53
|
+
return json.loads(text, object_pairs_hook=_reject_duplicate_members)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_document(path: Path) -> Any:
|
|
57
|
+
with open(path, encoding="utf-8") as handle:
|
|
58
|
+
return parse_document(handle.read())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# A minor version is what names a schema directory, and `load_schema` takes one from its
|
|
62
|
+
# caller, so it is checked before it becomes a path component.
|
|
63
|
+
_MINOR_VERSION = re.compile(r"\A\d+\.\d+\Z")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_schema(minor: str = SPEC_VERSION) -> dict[str, Any]:
|
|
67
|
+
"""Read the JSON Schema for one minor version of the format.
|
|
68
|
+
|
|
69
|
+
Spec §7 gives every minor its own schema and the directories are named for them, so
|
|
70
|
+
the version is an argument rather than a constant: a built wheel carries every minor
|
|
71
|
+
the vendored corpus had, not only the one this package validates against by default.
|
|
72
|
+
|
|
73
|
+
Two locations, and they hold the same bytes. ``_schema/`` is the copy a wheel carries,
|
|
74
|
+
which is the only one an installed package has; ``schema/`` is the vendored corpus
|
|
75
|
+
that copy is built from, which is the only one a checkout has, because a
|
|
76
|
+
``force-include`` reaches a built wheel and not an editable install. CI checks the
|
|
77
|
+
vendored corpus against the pinned specification commit, and then checks the wheel's
|
|
78
|
+
copy against the vendored corpus, so neither can quietly become a different schema.
|
|
79
|
+
"""
|
|
80
|
+
if not _MINOR_VERSION.match(minor):
|
|
81
|
+
raise ValueError(f"{minor!r} is not a DiveJSON minor version")
|
|
82
|
+
here = Path(__file__).resolve().parent
|
|
83
|
+
candidates = [
|
|
84
|
+
here / "_schema" / minor / "divejson.schema.json",
|
|
85
|
+
here.parent / "schema" / minor / "divejson.schema.json",
|
|
86
|
+
]
|
|
87
|
+
for candidate in candidates:
|
|
88
|
+
if candidate.is_file():
|
|
89
|
+
with open(candidate, encoding="utf-8") as handle:
|
|
90
|
+
return json.load(handle)
|
|
91
|
+
raise FileNotFoundError(
|
|
92
|
+
f"no schema for DiveJSON {minor} in the package or the vendored corpus"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_document(doc: Any, raw: str | None = None) -> list[Issue]:
|
|
97
|
+
"""Validate one parsed document; an empty result means conforming.
|
|
98
|
+
|
|
99
|
+
``raw`` is accepted for compatibility and unused: ``json.loads`` preserves the
|
|
100
|
+
text's member order in the parsed dict, so the member-order rule (spec §4) is
|
|
101
|
+
checked against ``doc`` itself — which also cannot be fooled by the words
|
|
102
|
+
"format" or "version" appearing inside some string value.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(doc, dict):
|
|
105
|
+
return [Issue("$", "a DiveJSON document is a JSON object")]
|
|
106
|
+
|
|
107
|
+
issues: list[Issue] = []
|
|
108
|
+
|
|
109
|
+
declared = doc.get("version")
|
|
110
|
+
if isinstance(declared, str) and declared != SPEC_VERSION:
|
|
111
|
+
issues.append(
|
|
112
|
+
Issue(
|
|
113
|
+
"version",
|
|
114
|
+
f"declares version {declared!r}; this validator implements {SPEC_VERSION} "
|
|
115
|
+
"(readers tolerate newer minors per spec §5.6, validators do not)",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
major = declared.split(".", 1)[0]
|
|
119
|
+
if major != SPEC_VERSION.split(".", 1)[0]:
|
|
120
|
+
return issues
|
|
121
|
+
|
|
122
|
+
issues.extend(_member_order_issues(doc))
|
|
123
|
+
issues.extend(_schema_issues(doc))
|
|
124
|
+
issues.extend(_semantic_issues(doc))
|
|
125
|
+
return issues
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _member_order_issues(doc: dict[str, Any]) -> list[Issue]:
|
|
129
|
+
keys = list(doc)
|
|
130
|
+
if not keys:
|
|
131
|
+
return []
|
|
132
|
+
if keys[0] != "format":
|
|
133
|
+
return [Issue("$", f'the first member is "{keys[0]}"; "format" MUST come first (spec §4)')]
|
|
134
|
+
if len(keys) > 1 and keys[1] != "version":
|
|
135
|
+
return [Issue("$", f'the second member is "{keys[1]}"; "version" MUST come second (spec §4)')]
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _schema_issues(doc: dict[str, Any]) -> list[Issue]:
|
|
140
|
+
validator = Draft202012Validator(load_schema(), format_checker=FormatChecker())
|
|
141
|
+
issues = []
|
|
142
|
+
for error in sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path)):
|
|
143
|
+
# A failure inside anyOf/if-then surfaces as a top-level error whose message
|
|
144
|
+
# dumps the whole instance; best_match descends to the telling suberror.
|
|
145
|
+
chosen = best_match([error]) or error
|
|
146
|
+
path = "/".join(str(part) for part in chosen.absolute_path)
|
|
147
|
+
issues.append(Issue(path, chosen.message))
|
|
148
|
+
return issues
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _present(obj: dict[str, Any], member: str) -> bool:
|
|
152
|
+
return obj.get(member) is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _semantic_issues(doc: dict[str, Any]) -> list[Issue]:
|
|
156
|
+
issues: list[Issue] = []
|
|
157
|
+
|
|
158
|
+
_check_datetime(doc, "exported_at", "", issues, require_offset=True)
|
|
159
|
+
|
|
160
|
+
diver = doc.get("diver")
|
|
161
|
+
seen_uuids: dict[str, str] = {}
|
|
162
|
+
if isinstance(diver, dict):
|
|
163
|
+
_claim_uuid(diver, "diver", seen_uuids, issues)
|
|
164
|
+
_check_datetime(diver, "created_at", "diver", issues)
|
|
165
|
+
|
|
166
|
+
collections = {
|
|
167
|
+
name: [row for row in doc.get(name) or [] if isinstance(row, dict)]
|
|
168
|
+
for name in (
|
|
169
|
+
"dives",
|
|
170
|
+
"trips",
|
|
171
|
+
"courses",
|
|
172
|
+
"sites",
|
|
173
|
+
"species",
|
|
174
|
+
"gear",
|
|
175
|
+
"gear_sets",
|
|
176
|
+
"gear_service_schedules",
|
|
177
|
+
"gear_service_records",
|
|
178
|
+
"certifications",
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for name, rows in collections.items():
|
|
183
|
+
for index, row in enumerate(rows):
|
|
184
|
+
here = f"{name}/{index}"
|
|
185
|
+
_claim_uuid(row, here, seen_uuids, issues)
|
|
186
|
+
_check_datetime(row, "created_at", here, issues)
|
|
187
|
+
|
|
188
|
+
known = {
|
|
189
|
+
name: {row["uuid"] for row in rows if isinstance(row.get("uuid"), str)}
|
|
190
|
+
for name, rows in collections.items()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for index, dive in enumerate(collections["dives"]):
|
|
194
|
+
here = f"dives/{index}"
|
|
195
|
+
_check_datetime(dive, "started_at", here, issues)
|
|
196
|
+
if _present(dive, "avg_depth") and _present(dive, "max_depth"):
|
|
197
|
+
try:
|
|
198
|
+
if dive["avg_depth"] > dive["max_depth"]:
|
|
199
|
+
issues.append(Issue(here, "avg_depth exceeds max_depth"))
|
|
200
|
+
except TypeError:
|
|
201
|
+
pass
|
|
202
|
+
_check_reference(dive, "trip_uuid", known["trips"], "trips", here, issues)
|
|
203
|
+
_check_reference(dive, "course_uuid", known["courses"], "courses", here, issues)
|
|
204
|
+
_check_reference_list(dive, "site_uuids", known["sites"], "sites", here, issues)
|
|
205
|
+
_check_reference_list(dive, "gear_uuids", known["gear"], "gear", here, issues)
|
|
206
|
+
_check_reference_list(dive, "species_uuids", known["species"], "species", here, issues)
|
|
207
|
+
|
|
208
|
+
for cyl_index, cylinder in enumerate(dive.get("cylinders") or []):
|
|
209
|
+
if not isinstance(cylinder, dict):
|
|
210
|
+
continue
|
|
211
|
+
cyl_path = f"{here}/cylinders/{cyl_index}"
|
|
212
|
+
if _present(cylinder, "oxygen") and _present(cylinder, "helium"):
|
|
213
|
+
try:
|
|
214
|
+
if cylinder["oxygen"] + cylinder["helium"] > 100:
|
|
215
|
+
issues.append(Issue(cyl_path, "oxygen + helium exceeds 100 percent"))
|
|
216
|
+
except TypeError:
|
|
217
|
+
pass
|
|
218
|
+
if _present(cylinder, "start_pressure") and _present(cylinder, "end_pressure"):
|
|
219
|
+
try:
|
|
220
|
+
if cylinder["end_pressure"] > cylinder["start_pressure"]:
|
|
221
|
+
issues.append(Issue(cyl_path, "end_pressure exceeds start_pressure"))
|
|
222
|
+
except TypeError:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
source_file = dive.get("source_file")
|
|
226
|
+
if isinstance(source_file, dict):
|
|
227
|
+
_claim_uuid(source_file, f"{here}/source_file", seen_uuids, issues)
|
|
228
|
+
|
|
229
|
+
profile = dive.get("profile")
|
|
230
|
+
if isinstance(profile, dict):
|
|
231
|
+
latest = 0
|
|
232
|
+
for channel in ("depth", "ceiling", "temperature"):
|
|
233
|
+
series = profile.get(channel)
|
|
234
|
+
if isinstance(series, dict):
|
|
235
|
+
latest = max(latest, _check_series(series, f"{here}/profile/{channel}", issues))
|
|
236
|
+
for series_index, series in enumerate(profile.get("pressures") or []):
|
|
237
|
+
if isinstance(series, dict):
|
|
238
|
+
latest = max(
|
|
239
|
+
latest, _check_series(series, f"{here}/profile/pressures/{series_index}", issues)
|
|
240
|
+
)
|
|
241
|
+
# Events are deliberately not folded into `latest`: `duration` spans the
|
|
242
|
+
# samples, and an event after the last one is conforming (spec §6.4). A
|
|
243
|
+
# marker pressed at the surface after the recorder's final sample is real
|
|
244
|
+
# logbook data, and requiring `duration` to swallow it would make a writer
|
|
245
|
+
# invent a sample span the file never had.
|
|
246
|
+
duration = profile.get("duration")
|
|
247
|
+
if isinstance(duration, (int, float)) and duration < latest:
|
|
248
|
+
issues.append(
|
|
249
|
+
Issue(
|
|
250
|
+
f"{here}/profile/duration",
|
|
251
|
+
f"duration {duration} does not cover the latest sample at {latest} (spec §6.4)",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
for index, trip in enumerate(collections["trips"]):
|
|
256
|
+
here = f"trips/{index}"
|
|
257
|
+
if _present(trip, "starts_on") and _present(trip, "ends_on"):
|
|
258
|
+
try:
|
|
259
|
+
if trip["ends_on"] < trip["starts_on"]:
|
|
260
|
+
issues.append(Issue(here, "ends_on precedes starts_on"))
|
|
261
|
+
except TypeError:
|
|
262
|
+
pass
|
|
263
|
+
for loc_index, location in enumerate(trip.get("locations") or []):
|
|
264
|
+
if not isinstance(location, dict):
|
|
265
|
+
continue
|
|
266
|
+
bbox = location.get("bbox")
|
|
267
|
+
if isinstance(bbox, dict):
|
|
268
|
+
try:
|
|
269
|
+
if bbox["south"] > bbox["north"]:
|
|
270
|
+
issues.append(
|
|
271
|
+
Issue(f"{here}/locations/{loc_index}/bbox", "south exceeds north")
|
|
272
|
+
)
|
|
273
|
+
except (KeyError, TypeError):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
for index, gear_set in enumerate(collections["gear_sets"]):
|
|
277
|
+
_check_reference_list(
|
|
278
|
+
gear_set, "gear_uuids", known["gear"], "gear", f"gear_sets/{index}", issues
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
for index, schedule in enumerate(collections["gear_service_schedules"]):
|
|
282
|
+
_check_reference(
|
|
283
|
+
schedule, "gear_uuid", known["gear"], "gear",
|
|
284
|
+
f"gear_service_schedules/{index}", issues,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for index, record in enumerate(collections["gear_service_records"]):
|
|
288
|
+
here = f"gear_service_records/{index}"
|
|
289
|
+
_check_reference(record, "gear_uuid", known["gear"], "gear", here, issues)
|
|
290
|
+
_check_reference(
|
|
291
|
+
record, "gear_service_schedule_uuid", known["gear_service_schedules"],
|
|
292
|
+
"gear_service_schedules", here, issues,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
for index, course in enumerate(collections["courses"]):
|
|
296
|
+
if _present(course, "starts_on") and _present(course, "ends_on"):
|
|
297
|
+
try:
|
|
298
|
+
if course["ends_on"] < course["starts_on"]:
|
|
299
|
+
issues.append(Issue(f"courses/{index}", "ends_on precedes starts_on"))
|
|
300
|
+
except TypeError:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
for index, certification in enumerate(collections["certifications"]):
|
|
304
|
+
here = f"certifications/{index}"
|
|
305
|
+
_check_reference(certification, "course_uuid", known["courses"], "courses", here, issues)
|
|
306
|
+
for member in ("front_file", "back_file"):
|
|
307
|
+
stored = certification.get(member)
|
|
308
|
+
if isinstance(stored, dict):
|
|
309
|
+
_claim_uuid(stored, f"{here}/{member}", seen_uuids, issues)
|
|
310
|
+
|
|
311
|
+
for index, item in enumerate(collections["gear"]):
|
|
312
|
+
_check_datetime(item, "archived_at", f"gear/{index}", issues)
|
|
313
|
+
|
|
314
|
+
return issues
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _claim_uuid(
|
|
318
|
+
obj: dict[str, Any], path: str, seen: dict[str, str], issues: list[Issue]
|
|
319
|
+
) -> None:
|
|
320
|
+
value = obj.get("uuid")
|
|
321
|
+
if not isinstance(value, str):
|
|
322
|
+
return
|
|
323
|
+
if value in seen:
|
|
324
|
+
issues.append(Issue(path, f"uuid {value} already used at {seen[value]}"))
|
|
325
|
+
else:
|
|
326
|
+
seen[value] = path
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_reference(
|
|
330
|
+
obj: dict[str, Any],
|
|
331
|
+
member: str,
|
|
332
|
+
targets: set[str],
|
|
333
|
+
collection: str,
|
|
334
|
+
path: str,
|
|
335
|
+
issues: list[Issue],
|
|
336
|
+
) -> None:
|
|
337
|
+
value = obj.get(member)
|
|
338
|
+
if isinstance(value, str) and value not in targets:
|
|
339
|
+
issues.append(Issue(f"{path}/{member}", f"references {value}, not present in {collection}"))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_reference_list(
|
|
343
|
+
obj: dict[str, Any],
|
|
344
|
+
member: str,
|
|
345
|
+
targets: set[str],
|
|
346
|
+
collection: str,
|
|
347
|
+
path: str,
|
|
348
|
+
issues: list[Issue],
|
|
349
|
+
) -> None:
|
|
350
|
+
values = obj.get(member)
|
|
351
|
+
if not isinstance(values, list):
|
|
352
|
+
return
|
|
353
|
+
for index, value in enumerate(values):
|
|
354
|
+
if isinstance(value, str) and value not in targets:
|
|
355
|
+
issues.append(
|
|
356
|
+
Issue(f"{path}/{member}/{index}", f"references {value}, not present in {collection}")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _check_series(series: dict[str, Any], path: str, issues: list[Issue]) -> int:
|
|
361
|
+
"""Check one channel; returns the latest sample time seen (0 if none)."""
|
|
362
|
+
times, values = series.get("times"), series.get("values")
|
|
363
|
+
if not (isinstance(times, list) and isinstance(values, list)):
|
|
364
|
+
return 0
|
|
365
|
+
if len(times) != len(values):
|
|
366
|
+
issues.append(Issue(path, f"times has {len(times)} samples but values has {len(values)}"))
|
|
367
|
+
numbers = [value for value in times if isinstance(value, (int, float))]
|
|
368
|
+
if any(later <= earlier for earlier, later in zip(numbers, numbers[1:])):
|
|
369
|
+
issues.append(Issue(path, "times is not strictly increasing"))
|
|
370
|
+
return max(numbers, default=0)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# \Z, not $: Python's $ also matches just before a trailing newline, which would let
|
|
374
|
+
# "…T08:00:00Z\n" through the grammar check with the newline silently dropped.
|
|
375
|
+
_DATE_TIME = re.compile(
|
|
376
|
+
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?\Z"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _check_datetime(
|
|
381
|
+
obj: dict[str, Any],
|
|
382
|
+
member: str,
|
|
383
|
+
path: str,
|
|
384
|
+
issues: list[Issue],
|
|
385
|
+
require_offset: bool = False,
|
|
386
|
+
) -> None:
|
|
387
|
+
value = obj.get(member)
|
|
388
|
+
if not isinstance(value, str):
|
|
389
|
+
return
|
|
390
|
+
where = f"{path}/{member}" if path else member
|
|
391
|
+
match = _DATE_TIME.match(value)
|
|
392
|
+
if not match:
|
|
393
|
+
issues.append(Issue(where, f"{value!r} is not a DiveJSON date-time"))
|
|
394
|
+
return
|
|
395
|
+
base, fraction, offset = match.groups()
|
|
396
|
+
# Normalize before the calendar check: fromisoformat is case-sensitive about Z
|
|
397
|
+
# and, on Python 3.10, insists on exactly 3 or 6 fractional digits — both stricter
|
|
398
|
+
# than the format's grammar.
|
|
399
|
+
normalized = base
|
|
400
|
+
if fraction:
|
|
401
|
+
normalized += "." + (fraction[1:] + "000000")[:6]
|
|
402
|
+
if offset:
|
|
403
|
+
normalized += "+00:00" if offset in ("Z", "z") else offset
|
|
404
|
+
try:
|
|
405
|
+
datetime.fromisoformat(normalized)
|
|
406
|
+
except ValueError:
|
|
407
|
+
issues.append(Issue(where, f"{value!r} is not a real calendar date-time"))
|
|
408
|
+
return
|
|
409
|
+
if require_offset and offset is None:
|
|
410
|
+
issues.append(Issue(where, "must carry a UTC offset — it is generated, not recorded history (spec §5.2)"))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: divejson
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Validator, converters and conformance runner for DiveJSON, an open dive-log interchange format
|
|
5
|
+
Project-URL: Homepage, https://divejson.org
|
|
6
|
+
Project-URL: Repository, https://github.com/divejson/divejson-py
|
|
7
|
+
Project-URL: Specification, https://github.com/divejson/divejson/blob/main/spec/divejson.md
|
|
8
|
+
Project-URL: Changelog, https://github.com/divejson/divejson-py/blob/main/CHANGELOG.md
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dive-log,diving,interchange,json,scuba
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: fitdecode>=0.11.0
|
|
14
|
+
Requires-Dist: jsonschema>=4.18
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# divejson
|
|
20
|
+
|
|
21
|
+
Python tools for [DiveJSON](https://divejson.org), an open interchange format for scuba
|
|
22
|
+
dive logs: the validator, the converters that read other dive-log formats into DiveJSON,
|
|
23
|
+
and `divejson conform`, the conformance runner an implementation of the format is checked
|
|
24
|
+
with.
|
|
25
|
+
|
|
26
|
+
The format itself — the normative specification, the JSON Schema and the conformance
|
|
27
|
+
corpus — lives in [divejson/divejson](https://github.com/divejson/divejson). This is an
|
|
28
|
+
implementation of it, in a repository of its own, and it **vendors** that repository's
|
|
29
|
+
schema, fixtures and mapping documents from the commit named in
|
|
30
|
+
[`SPEC_REF`](https://github.com/divejson/divejson-py/blob/main/SPEC_REF). CI checks the
|
|
31
|
+
copy against the specification at that commit on every pull request, so which version of
|
|
32
|
+
the format this package implements is a fact in the tree rather than a claim in a
|
|
33
|
+
sentence.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install divejson
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Python 3.10 or newer.
|
|
42
|
+
|
|
43
|
+
## Validate a document
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
divejson validate my-logbook.divejson
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The JSON Schema, and then the requirements the specification states in prose and a schema
|
|
50
|
+
cannot — identifier uniqueness, referential closure, profile-series integrity, the member
|
|
51
|
+
order, the UTC offset on `exported_at`. Exit status is non-zero if any file fails, with
|
|
52
|
+
one line per violation.
|
|
53
|
+
|
|
54
|
+
## Convert a logbook into DiveJSON
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
divejson convert my-logbook.uddf
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
writes `my-logbook.divejson` beside the input and reports, line by line, what the source
|
|
61
|
+
did not carry — no UTC offsets, a cylinder whose size nobody recorded, coordinates that
|
|
62
|
+
were `0.000000`. **Nothing absent is filled in**: that report is the other half of the
|
|
63
|
+
output, not a diagnostic, and it is what tells a diver which parts of their history their
|
|
64
|
+
old application never kept. The mapping rules, the three places UDDF is genuinely
|
|
65
|
+
ambiguous, and what is deliberately left unmapped are in
|
|
66
|
+
[`docs/uddf-mapping.md`](https://github.com/divejson/divejson-py/blob/main/docs/uddf-mapping.md).
|
|
67
|
+
|
|
68
|
+
## Run a conformance corpus
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
divejson conform fixtures --strict
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Walks a corpus — `valid/` documents that must validate, `invalid/` ones that must not,
|
|
75
|
+
and one directory of reader pairs per source format, named for the format — and says what
|
|
76
|
+
this implementation makes of it. The exit status distinguishes two ways of not passing:
|
|
77
|
+
|
|
78
|
+
| status | meaning |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| 0 | every case passed |
|
|
81
|
+
| 1 | a case **failed**: a valid document that does not validate, an invalid one that does, a pair whose produced document differs from the expected one |
|
|
82
|
+
| 2 | the corpus's **shape** is wrong: an empty directory, a pair missing one of its halves, or pairs for a format this implementation does not register — cases that never ran, which is not the same answer as cases that failed |
|
|
83
|
+
|
|
84
|
+
`--only <format>` and `--skip <format>` narrow the run to a format's pairs, and neither
|
|
85
|
+
reaches `valid/` or `invalid/`. `--strict` turns a format this implementation reads and
|
|
86
|
+
the corpus has no pairs for from a warning into an error.
|
|
87
|
+
|
|
88
|
+
## What it reads
|
|
89
|
+
|
|
90
|
+
| format | what this package does | versions |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| DiveJSON | validates | 1.0 |
|
|
93
|
+
| UDDF | reads into DiveJSON | 3.0 – 3.2.3 |
|
|
94
|
+
|
|
95
|
+
The UDDF reader matches element names rather than the declared version, so older
|
|
96
|
+
documents using the same names are read too: the corpus it is checked against carries
|
|
97
|
+
2.2.0, 3.2.0, 3.2.1 and 3.2.2, under three different root shapes.
|
|
98
|
+
|
|
99
|
+
## Releasing
|
|
100
|
+
|
|
101
|
+
A release is a tag. Move `__version__` in `divejson/__init__.py` — the build reads the
|
|
102
|
+
version from there, so it is the only place it lives — head the changelog's new entries
|
|
103
|
+
with it, land that, and push `v<version>`.
|
|
104
|
+
`.github/workflows/release.yml` then builds the sdist and, from it, the wheel; checks
|
|
105
|
+
that the tag names the version it built and that the wheel actually runs the corpus; and
|
|
106
|
+
publishes to PyPI by trusted publishing, with no API token anywhere. Nothing else
|
|
107
|
+
publishes, and a release is the only thing another repository can pin.
|
|
108
|
+
|
|
109
|
+
## Notices
|
|
110
|
+
|
|
111
|
+
`fitdecode` (MIT) is a dependency of this package. The FIT Protocol and FIT file format
|
|
112
|
+
are proprietary to Garmin; this project is not affiliated with or endorsed by Garmin, and
|
|
113
|
+
carries no part of the FIT SDK.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [`LICENSE`](https://github.com/divejson/divejson-py/blob/main/LICENSE). The
|
|
118
|
+
vendored `schema/`, `fixtures/` and `docs/` are MIT in the specification repository too;
|
|
119
|
+
the specification prose itself, which is CC BY 4.0, is not vendored here.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
divejson/__init__.py,sha256=8107hVilQv8ZWqEqE-ewVAatPwKyXJiGwZb5weFP6Vc,1893
|
|
2
|
+
divejson/cli.py,sha256=iIyUnSsua-VN_vtnLbafe32TYo4ooIxEVC9oLNeVlfw,11584
|
|
3
|
+
divejson/conform.py,sha256=fFxcpabiVsndkC1fYLgwwXQ2AtYxeFzQ-VjVa99wYHc,17731
|
|
4
|
+
divejson/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
divejson/uddf.py,sha256=8bONZQKNNE_dJAeyVVH03JyBHJmh1OT629MtSFz6QaY,62370
|
|
6
|
+
divejson/validate.py,sha256=ZqaJaonjJLiCGmeVZGgYgG4YgPkvcSz6oSeio0dpTco,16310
|
|
7
|
+
divejson/_schema/1.0/divejson.schema.json,sha256=yZ5DMzdsn1Z0JCoR6nZfWGFksKK4U8sBMn2LqnGm2aA,18662
|
|
8
|
+
divejson-0.2.0.dist-info/METADATA,sha256=8qHTJo_HfSjf4wvKjW4nLGsnsK4PJtZzmI19_jR29UU,5208
|
|
9
|
+
divejson-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
divejson-0.2.0.dist-info/entry_points.txt,sha256=yE5K_7Opzyg7uC3F6TQRZHK3MF-j78Nzc3eSM1k8pNc,47
|
|
11
|
+
divejson-0.2.0.dist-info/licenses/LICENSE,sha256=wrnnIaQFB-aNoMTEqnbrC_MxBvqMfbOGhsHpy94zpK4,1077
|
|
12
|
+
divejson-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The DiveJSON Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|