Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ this file is about the package, whose version moves independently.

## Unreleased

- **Breaking: a trip is a sequence of parts, and records no dates of its own.** §6.8 and the
new §6.9a of
[the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) replace
a trip's `locations` with `parts`, each part carrying its own OPTIONAL `starts_on`, `ends_on`
and nested `location`. A trip's own `starts_on` and `ends_on` are gone, so anything reaching
into a document for them finds nothing and `divejson validate` refuses either as an undefined
member; a trip's span is the earliest `starts_on` among its parts and the latest `ends_on`,
and a trip whose parts carry none has no span. §3's `ends_on ≥ starts_on` reads on a part, so
that issue is now reported at `trips/<i>/parts/<j>` and a bounding box's at
`trips/<i>/parts/<j>/location/bbox`.

- **The UDDF reader keeps each `<trippart>`'s own dates and place**, where it returned the
earliest start, the latest end and a flat list of names. A trip whose parts carry no dates is
carried rather than dropped, the REQUIRED `starts_on` that forced that being gone; a
`<trippart>` with a `<geography>` and no `<name>` keeps its dates and loses the place, §6.9
still requiring a location's name; and one carrying neither a name nor a date produces no part
at all, which is what lets a trip with no parts come back as one. The writer emits one
`<trippart>` per part with its own `<dateoftrip>`, omits that element for a part with neither
date, and writes a part's single date into both of its attributes — both are required there,
and dropping the element would lose the date the document held.

## 0.8.0

- **Breaking: a course's `agency` is OPTIONAL.** §6.17 of
Expand Down
2 changes: 1 addition & 1 deletion SPEC_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
d92517fbd669d3c9fcd8ef6e89fb8d231e8eb019
373bf78002d15102e60f8913f31b74dbe7c12ffc
84 changes: 51 additions & 33 deletions divejson/uddf.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,10 +694,10 @@ def read_sites(self) -> list[dict[str, Any]]:
def read_trips(self) -> list[dict[str, Any]]:
"""`<divetrip><trip>` as Trip records.

A `<trippart>` becomes a Trip Location: UDDF models a trip as a sequence of parts,
each with its own place and dates, and §6.9's location list is the nearest thing
this format has. The trip's own dates are the span of its parts, because `tripType`
records none of its own.
A `<trippart>` is a §6.9a part, which is as close to an identity as this mapping
gets: both formats model a trip as a sequence of stretches each carrying its own
dates and its own place. Neither records dates on the trip itself, so a trip whose
parts carry none has no span in either and is still a trip.
"""
trips: list[dict[str, Any]] = []
for index, element in enumerate(_kids(_kid(self.root, "divetrip"), "trip")):
Expand All @@ -707,15 +707,7 @@ def read_trips(self) -> list[dict[str, Any]]:
self.note(where, "the trip has no name, which the format requires of one; it is dropped (spec §6.8)", "dropped")
continue

starts, ends, locations, notes = self.read_trip_parts(element, where)
if starts is None:
self.note(
where,
"the trip records no dates, and the format requires a start date; it is dropped along with "
"the dives' membership of it (spec §6.8)",
"dropped",
)
continue
parts, notes = self.read_trip_parts(element, where)
claimed, carried = self.uuid_for("trip", _attr(element, "id"), where, index)
if claimed is None:
continue
Expand All @@ -729,65 +721,91 @@ def read_trips(self) -> list[dict[str, Any]]:
continue

trip: dict[str, Any] = {"uuid": claimed, "name": self.capped(name, MAX_NAME, where, "the trip name")}
if locations:
trip["locations"] = locations
trip["starts_on"] = starts
if ends is not None and ends >= starts:
trip["ends_on"] = ends
elif ends is not None:
self.note(where, f"the trip ends on {ends}, before it starts on {starts}; the end date is dropped", "dropped")
if parts:
trip["parts"] = parts
if notes:
trip["notes"] = notes

trips.append(trip)
return trips

def read_trip_parts(
self, element: ET.Element, where: str
) -> tuple[str | None, str | None, list[dict[str, Any]], str | None]:
starts: list[str] = []
ends: list[str] = []
locations: list[dict[str, Any]] = []
def read_trip_parts(self, element: ET.Element, where: str) -> tuple[list[dict[str, Any]], str | None]:
"""Every `<trippart>` as a part, in file order, and the trip's note.

A part with neither a name nor a date is **no part at all**, which is what lets a
trip with no parts survive a round trip: `tripType` requires at least one
`<trippart>`, so a writer with nothing to put in one emits exactly this element
(`docs/uddf-writing.md`), and reading it back as nothing is what closes the circle.
Its note is still collected — a note belongs to the trip (§6.9a gives a part none).
"""
parts: list[dict[str, Any]] = []
paragraphs: list[str] = []

for part_index, part in enumerate(_kids(element, "trippart")):
part_where = f"{where}/trippart/{part_index}"
date_of_trip = _kid(part, "dateoftrip")
for attribute, collected in (("startdate", starts), ("enddate", ends)):
dates: dict[str, str] = {}
for attribute, member in (("startdate", "starts_on"), ("enddate", "ends_on")):
raw = _attr(date_of_trip, attribute)
if raw is None:
continue
value, _ = _date_time(raw)
if value is None:
self.note(part_where, f"{attribute} is {raw!r}, which is not a date; dropped", "dropped")
else:
collected.append(value[:10])
dates[member] = value[:10]
starts, ends = dates.get("starts_on"), dates.get("ends_on")
if starts is not None and ends is not None and ends < starts:
self.note(
part_where,
f"the part ends on {ends}, before it starts on {starts}; the end date is dropped",
"dropped",
)
ends = None

geography = _kid(part, "geography")
part_name = _text_of(part, "name")
display_name = _text_of(geography, "location")
location: dict[str, Any] | None = None
if part_name:
location: dict[str, Any] = {"name": self.capped(part_name, MAX_NAME, part_where, "the trip part's name")}
location = {"name": self.capped(part_name, MAX_NAME, part_where, "the trip part's name")}
if display_name and display_name != part_name:
location["display_name"] = self.capped(display_name, MAX_DISPLAY_NAME, part_where, "the location")
position = self.position(geography, part_where)
if position:
location["position"] = position
locations.append(location)
elif geography is not None:
# What the finding has to say is what survives, and that turns on the dates
# — a dateless nameless part is nothing at all once the place goes, which is
# the same split `uddf_write.nameless_part` makes from the other side.
kept = (
"the part keeps its dates"
if starts is not None or ends is not None
else "the part records no dates either, so nothing of it is carried"
)
self.note(
part_where,
"the trip part has no name, which the format requires of a location; the place is dropped "
"(spec §6.9)",
f"the trip part has no name, which the format requires of a location; the place is "
f"dropped and {kept} (spec §6.9)",
"dropped",
)

part_notes = self.notes_text(part, part_where)
if part_notes:
paragraphs.append(part_notes)

record: dict[str, Any] = {}
if starts is not None:
record["starts_on"] = starts
if ends is not None:
record["ends_on"] = ends
if location is not None:
record["location"] = location
if record:
parts.append(record)

joined = self.capped("\n\n".join(paragraphs), MAX_NOTES, where, "the trip note") if paragraphs else None
return (min(starts) if starts else None), (max(ends) if ends else None), locations, joined
return parts, joined

# -- gear --------------------------------------------------------------------

Expand Down
93 changes: 65 additions & 28 deletions divejson/uddf_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,41 +956,43 @@ def geography(
# -- trips -------------------------------------------------------------------

def divetrip_element(self) -> ET.Element | None:
"""`<divetrip>`, one `<trippart>` per §6.9 location.

UDDF models a trip as a sequence of parts, each with its own place, and that is the
only shape a list of locations fits: the reader takes a trip's span as the span of
its parts and its locations from their names, so a part per location comes back as
the list it was written from. A trip with no locations still needs one part —
`tripType` requires at least one — and it gets a nameless one, an empty `<name>`
being a valid `xs:string` that reads back as no location rather than as one.
"""`<divetrip>`, one `<trippart>` per §6.9a part.

Both formats model a trip as a sequence of stretches each carrying its own dates
and its own place, so a part goes out whole rather than having its dates lifted to
the trip. A trip with **no** parts still needs one `<trippart>` — `tripType`
requires at least one — and gets a nameless, dateless one, which reads back as no
part rather than as an empty one and so is reported as nothing lost.

The trip's note goes on the first part and nowhere else: a reader joins every
part's notes, so writing it on each would hand back one copy per part.
"""
trips = self.document.get("trips")
if not trips:
return None
divetrip = ET.Element("divetrip")
for index, trip in enumerate(trips):
where = f"trips/{index}"
self.unmapped(
where, trip, frozenset({"uuid", "name", "locations", "starts_on", "ends_on", "notes"})
)
self.unmapped(where, trip, frozenset({"uuid", "name", "parts", "notes"}))
element = _sub(divetrip, "trip", id=_uddf_id("trip", trip["uuid"]))
_sub(element, "name", str(trip.get("name") or ""))
locations = trip.get("locations") or [None]
for part_index, location in enumerate(locations):
part_where = f"{where}/locations/{part_index}"
parts = trip.get("parts") or [None]
for part_index, record in enumerate(parts):
part_where = f"{where}/parts/{part_index}"
location = None if record is None else record.get("location")
# `trippartType` is an `xs:sequence`: name, dateoftrip, geography, notes.
part = _sub(element, "trippart")
if record is not None:
self.unmapped(part_where, record, frozenset({"starts_on", "ends_on", "location"}))
if location is None:
_sub(part, "name", "")
if record is not None:
self.nameless_part(part_where, record)
else:
self.unmapped(part_where, location, frozenset({"name", "display_name", "position"}))
_sub(part, "name", str(location.get("name") or ""))
# The dates and the note belong to the trip and not to any one part, so they
# go on the first: the reader takes the span of every part's dates and joins
# every part's notes, both of which return what one part carried.
if part_index == 0:
self.date_of_trip(part, where, trip)
if record is not None:
self.date_of_trip(part, part_where, record)
if location is not None:
# `display_name` and nothing else: the reader takes a part's
# `<geography><location>` as the display name and only where it differs
Expand All @@ -1003,25 +1005,60 @@ def divetrip_element(self) -> ET.Element | None:
self.notes_of(part, where, trip)
return divetrip

def date_of_trip(self, part: ET.Element, where: str, trip: dict[str, Any]) -> None:
def nameless_part(self, where: str, record: dict[str, Any]) -> None:
"""The empty `<name>` a placeless part is written with, reported.

`simpleNamedType` makes `<name>` mandatory and the part has nothing for it, so what
the finding has to say is what a reader will take the placeholder as — and that
turns on the part's dates. A dated one comes back as the placeless part it was; one
carrying neither a place nor a date does not come back at all, being the same
element as the floor a partless trip is written with.
"""
if record.get("starts_on") or record.get("ends_on"):
self.note(
where,
"the part records no place, and UDDF's <trippart> requires a <name>; an empty one is "
"written, which reads back as the dated placeless part it is",
"absent",
)
else:
self.note(
where,
"the part records neither a place nor a date, and UDDF's <trippart> requires a <name>; an "
"empty one is written, which is the element a trip with no parts is written as and reads "
"back as no part at all",
"absent",
)

def date_of_trip(self, part: ET.Element, where: str, record: dict[str, Any]) -> None:
"""`<dateoftrip>`, whose two attributes are both `use="required"`.

A trip with no end date has nothing to put in `enddate`, and UDDF has no spelling
for an open one — so the start date is repeated and the report says what a reader
will make of it, which is a trip that ended the day it began.
The element itself is `minOccurs="0"`, so a part with neither date gets none of it
and loses nothing. A part with **one** of the two has nothing for the other
attribute, and UDDF has no spelling for an open stretch — so the date it does have
is written into both and the report says what a reader will make of that, which is
a stretch that began and ended on one day. Dropping the element instead would lose
the date the source did record.
"""
starts = trip.get("starts_on")
if not starts:
starts, ends = record.get("starts_on"), record.get("ends_on")
if not starts and not ends:
return
ends = trip.get("ends_on")
if not ends:
self.note(
where,
"the trip records no end date, and UDDF's <dateoftrip> requires one; the start date is "
"written there, so a reader sees a trip that ended the day it began",
"the part records no end date, and UDDF's <dateoftrip> requires both; the start date is "
"written into both, so a reader sees a stretch that began and ended on one day",
"absent",
)
ends = starts
elif not starts:
self.note(
where,
"the part records no start date, and UDDF's <dateoftrip> requires both; the end date is "
"written into both, so a reader sees a stretch that began and ended on one day",
"absent",
)
starts = ends
# `xs:dateTime` where DiveJSON holds a plain date, so each is widened to midnight —
# and the reader takes the date back off the front, which is what makes it exact.
_sub(part, "dateoftrip", startdate=f"{starts}T00:00:00", enddate=f"{ends}T00:00:00")
Expand Down
22 changes: 12 additions & 10 deletions divejson/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,22 +279,24 @@ def _semantic_issues(doc: dict[str, Any]) -> list[Issue]:

for index, trip in enumerate(collections["trips"]):
here = f"trips/{index}"
if _present(trip, "starts_on") and _present(trip, "ends_on"):
try:
if trip["ends_on"] < trip["starts_on"]:
issues.append(Issue(here, "ends_on precedes starts_on"))
except TypeError:
pass
for loc_index, location in enumerate(trip.get("locations") or []):
for part_index, part in enumerate(trip.get("parts") or []):
if not isinstance(part, dict):
continue
part_path = f"{here}/parts/{part_index}"
if _present(part, "starts_on") and _present(part, "ends_on"):
try:
if part["ends_on"] < part["starts_on"]:
issues.append(Issue(part_path, "ends_on precedes starts_on"))
except TypeError:
pass
location = part.get("location")
if not isinstance(location, dict):
continue
bbox = location.get("bbox")
if isinstance(bbox, dict):
try:
if bbox["south"] > bbox["north"]:
issues.append(
Issue(f"{here}/locations/{loc_index}/bbox", "south exceeds north")
)
issues.append(Issue(f"{part_path}/location/bbox", "south exceeds north"))
except (KeyError, TypeError):
pass

Expand Down
10 changes: 5 additions & 5 deletions docs/converting.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,11 @@ document. `exported_at` is the moment of conversion, always offset-aware.
logbook. Any mapping added later that lands a source string on a constrained member owes
the same guard.
- **A record whose format-required member the source never recorded goes, along with every
reference to it** — a trip with no dates (§6.8 makes `starts_on` REQUIRED), a site or a
gear item with no name (§6.10, §6.12) — rather than gaining an invented one. §5.3 forbids
a dangling reference, so the references go with the record. A source that records nothing
at all about a logbook's owner produces no `diver` member (§6.1): minting an identity for
one would be §5.4's fabrication applied to people.
reference to it** — a trip, a site or a gear item with no name (§6.8, §6.10, §6.12) —
rather than gaining an invented one. §5.3 forbids a dangling reference, so the references
go with the record. A source that records nothing at all about a logbook's owner produces
no `diver` member (§6.1): minting an identity for one would be §5.4's fabrication applied
to people.
- **A source record that is not a dive at all is skipped, and reported.** A run, a swim, an
activity with no depth: a tracker writes them in the same shape as a dive, and §6.2's
object is a dive. Skipping it and saying so is the honest answer; a marker under
Expand Down
Loading
Loading