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/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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ divejson = divejson.cli:main
@@ -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.