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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ this file is about the package, whose version moves independently.

## Unreleased

- **Breaking: a diver carries a date of birth, a phone, emergency contacts and insurances,
and the Diver's strings are bounded.** §6.1 of
[the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) adds
`born_on`, `phone`, `emergency_contacts` and `insurances`, and bounds `name` at 255,
`username` at 64 and `email` at 255, so `divejson validate` refuses a document it accepted
before if one of those is longer. The UDDF reader takes `born_on` from `<birthdate>`,
`phone` from the first `<phone>` or else the first `<mobilephone>`, and one insurance per
`<insurance>`, and an owner recording any of them is a diver even with no name. An
insurance with no `<name>` is dropped, a phone or an email past its bound is dropped rather
than cut and an insurer's name is capped at 255, each with a report line. The writer puts
the same three into `<owner>` in the XSD's order, dates widened to midnight, and reports
`emergency_contacts` and an insurance's `number`, which UDDF has no element for.

## 0.10.0

- **Breaking: a place is one object, and a dive site carries it.** §6.9 of
Expand Down
2 changes: 1 addition & 1 deletion SPEC_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
977288489f1312ff94451ec7379fcef1f27f6d75
c5034f083d178de0d4c4130dd989213e8031db92
6 changes: 3 additions & 3 deletions divejson/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,9 @@ def _recorded_owner(diver: dict[str, Any]) -> dict[str, Any]:
Without the UUID, which for this one record says nothing: it is derived from an
`<owner id>` that every UDDF writer spells `owner`, so it is the same for two different
people and different for one person whose two exports spell it differently. What is
left is the name and the email — and comparing those is what separates an archive of
one diver's dives, where every file repeats the same owner and nothing is lost, from
one where something is.
left is every member the file recorded about the person — and comparing those is what
separates an archive of one diver's dives, where every file repeats the same owner and
nothing is lost, from one where something is.

Any difference is a drop, and the report says only that. Two people's exports in one
zip and one person's two files where the later adds an email the first omitted are both
Expand Down
149 changes: 127 additions & 22 deletions divejson/uddf.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,16 @@

# `MAX_NOTES` and `MAX_NAME` are `converter.py`'s: every adapter meets those two, and
# §6.9's `name` is one of them — a place's name shares the 255 every name in the format
# has. The two below are UDDF's own, being the only reader that fills the members they cap.
# §6.12's `serial` is 1-64 rather than the 255 its neighbours share, and the reason is worth
# knowing: a gear serial longer than a device's (§6.4b) could never equal one, and equality
# between the two is what says a kit item and a device are one machine.
# has, and so does an insurer's. The caps below are UDDF's own, being the only reader that
# fills the members they cap. §6.12's `serial` is 1-64 rather than the 255 its neighbours
# share, and the reason is worth knowing: a gear serial longer than a device's (§6.4b) could
# never equal one, and equality between the two is what says a kit item and a device are
# one machine. §6.1's phone and email are never cut to theirs, only dropped past them — a
# number or an address with its end missing is a wrong one rather than a short one.
MAX_FULL_NAME = 512
MAX_SERIAL = 64
MAX_PHONE = 32
MAX_EMAIL = 255
MIN_PO2_LIMIT = Decimal("0.4")
MAX_PO2_LIMIT = Decimal("2.0")
MIN_SURFACE_PRESSURE = Decimal("0.4")
Expand Down Expand Up @@ -501,19 +505,76 @@ def capped(self, value: str, limit: int, where: str, member: str) -> str:
return capped(value, limit, note=self.note, where=where, member=member)

def email(self, value: str | None, where: str) -> str | None:
"""`<contact><email>` when it is an address, and nothing when it is not.
"""`<contact><email>` when it is an address §6.1 can hold, and nothing when it is not.

Every other source string reaches a member the format types as free text, where the
only limit is a length this converter caps. `email` is the exception — the schema
types it as an email address, so a `-` or an `n/a` is a value the member cannot
hold. Without this the whole conversion fails on it: the output would not validate,
which this module treats as its own bug, so one unusable header field would discard
an entire logbook instead of costing it one member and a line in the report.
only limit is a length. `email` is the exception — the schema types it as an email
address, so a `-` or an `n/a` is a value the member cannot hold. Without this the
whole conversion fails on it: the output would not validate, which this module
treats as its own bug, so one unusable header field would discard an entire logbook
instead of costing it one member and a line in the report. An address past §6.1's
bound is the same case.
"""
if value is None or _EMAIL.match(value):
return value
self.note(where, f"the recorded email {value!r} is not an address; read as no email recorded", "dropped")
return None
if value is None:
return None
if not _EMAIL.match(value):
self.note(where, f"the recorded email {value!r} is not an address; read as no email recorded", "dropped")
return None
if len(value) > MAX_EMAIL:
self.note(
where,
f"the recorded email is {len(value)} characters and the format allows {MAX_EMAIL}; an address "
"cut short is a wrong one, so it is dropped",
"dropped",
)
return None
return value

def phone(self, contact: ET.Element | None, where: str) -> str | None:
"""The first `<phone>`, else the first `<mobilephone>`, reporting every other one.

§6.1 carries one number the way it carries one address, and `contactType` holds any
number of each. A first number past §6.1's bound is dropped rather than cut, for the
reason `MAX_PHONE` gives, and the next is not read in its place.
"""
recorded = [
(tag, value)
for tag in ("phone", "mobilephone")
for value in (_text(kid) for kid in _kids(contact, tag))
if value
]
if not recorded:
return None
for tag, value in recorded[1:]:
self.note(
where, f"§6.1 carries one phone, the first the owner records; <{tag}> {value!r} is not read", "dropped"
)
_, value = recorded[0]
if len(value) > MAX_PHONE:
self.note(
where,
f"the recorded phone is {len(value)} characters and the format allows {MAX_PHONE}; a number cut "
"short is a wrong one, so it is dropped",
"dropped",
)
return None
return value

def date_of(self, element: ET.Element | None, where: str, member: str) -> str | None:
"""The date an `encapsulatedDateTimeType` holds, taken off the front of its `<datetime>`.

The slot is an `xs:dateTime` and §6.1's members are dates, so a time of day is one a
writer supplied only because the slot demands one and is discarded without a finding;
a bare date, which UDDF's own examples write, reads the same way.
"""
raw = _text_of(element, "datetime")
if raw is None:
return None
value, _ = _date_time(raw)
if value is None:
self.note(where, f"{member} is {raw!r}, which is not a date; dropped", "dropped")
return None
return value[:10]

def notes_text(self, parent: ET.Element | None, where: str) -> str | None:
"""A `<notes>` block as one string. Its `<link>` children carry no note text."""
Expand Down Expand Up @@ -621,19 +682,28 @@ def read_diver(self) -> dict[str, Any] | None:
than an identity and every UDDF writer in the corpus spells it `owner`. So a second
member's diver is written out — without the identity that is not its own — and the
merge is where a logbook's one owner is chosen and the rest reported. Collapsing it
here would discard a second person's name and email in silence.
here would discard what a second person's file records about them in silence.

**An owner is a diver when it records anything this maps**, the id aside: an
`<owner>` with empty names and a `<birthdate>` is a nameless diver with a date of
birth, and one whose only content is a kit list is no diver at all.
"""
owner = _dig(self.root, "diver", "owner")
if owner is None:
return None
where = "diver"
personal = _kid(owner, "personal")
contact = _kid(owner, "contact")
names = [
text
for text in (_text_of(owner, "personal", part) for part in ("firstname", "middlename", "lastname"))
if text
text for text in (_text_of(personal, part) for part in ("firstname", "middlename", "lastname")) if text
]
email = self.email(_text_of(owner, "contact", "email"), where)
if not names and not email:
recorded: dict[str, Any] = {
"email": self.email(_text_of(contact, "email"), where),
"phone": self.phone(contact, where),
"born_on": self.date_of(_kid(personal, "birthdate"), where, "the date of birth"),
"insurances": self.read_insurances(owner, where),
}
if not names and not any(recorded.values()):
self.note(where, "the source records nothing about the logbook's owner; no diver is written (spec §6.1)", "absent")
return None

Expand All @@ -643,10 +713,45 @@ def read_diver(self) -> dict[str, Any] | None:
diver["uuid"] = claimed
if names:
diver["name"] = self.capped(" ".join(names), MAX_NAME, where, "the diver's name")
if email:
diver["email"] = email
diver.update((member, value) for member, value in recorded.items() if value)
return diver

def read_insurances(self, owner: ET.Element, where: str) -> list[dict[str, Any]]:
"""`<diveinsurances><insurance>` as §6.1's Insurance objects, in file order.

`<name>` is the insurer and REQUIRED of one, so an insurance whose `<name>` holds no
text is dropped rather than carried without it. The XSD requires the element and
types it as nothing, so `<name/>` is valid UDDF; mapped without a `provider` it would
fail the schema, and the whole file with it. What §6.1's Insurance has no member for
is reported, and nothing fills its `number`: `insuranceType` has no element for one.
"""
insurances: list[dict[str, Any]] = []
for index, element in enumerate(_kids(_kid(owner, "diveinsurances"), "insurance")):
here = f"{where}/insurance/{index}"
provider = _text_of(element, "name")
if not provider:
self.note(
here,
"the insurance has no name, which the format requires of one as its insurer; it is dropped "
"(spec §6.1)",
"dropped",
)
continue
insurance: dict[str, Any] = {"provider": self.capped(provider, MAX_NAME, here, "the insurer's name")}
expires_on = self.date_of(_kid(element, "validdate"), here, "the insurance's validdate")
if expires_on:
insurance["expires_on"] = expires_on
aliases = (_text(kid) for kid in _kids(element, "aliasname"))
unread = [f"<aliasname> {alias!r}" for alias in aliases if alias]
if _text_of(element, "issuedate", "datetime"):
unread.append("<issuedate>")
if any(_text(para) for para in _kids(_kid(element, "notes"), "para")):
unread.append("<notes>")
for what in unread:
self.note(here, f"§6.1's Insurance has no member for {what}; it is not read", "dropped")
insurances.append(insurance)
return insurances

# -- sites -------------------------------------------------------------------

def read_sites(self) -> list[dict[str, Any]]:
Expand Down
56 changes: 40 additions & 16 deletions divejson/uddf_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,41 +594,65 @@ def generator_element(self) -> ET.Element:
def diver_element(self) -> ET.Element | None:
"""`<diver><owner>`, which is also the only place a logbook's gear can live.

So the element is written whenever there is either an owner to describe or a piece
of kit to hang on one, and the two are independent: `<equipment>` sits inside
`<owner>`, and a logbook with gear and no diver would otherwise lose the whole list
to a member it has nothing to do with. An owner with nothing recorded about the
person gets empty names — a valid `xs:string`, which the reader reads back as no
diver at all rather than as a nameless one — and a note saying so.
So the element is written whenever there is either an owner to describe — any member
this maps, `uuid` aside — or a piece of kit to hang on one, and the two are
independent: `<equipment>` sits inside `<owner>`, and a logbook with gear and no
diver would otherwise lose the whole list to a member it has nothing to do with. A
diver with no `name` gets both names empty, `personalType` requiring them whatever
else the owner carries — valid `xs:string`s, which the reader reads back as a
nameless diver beside anything else this maps and as no diver at all beside a kit
list alone.

The owner's children go in the XSD's order, `<owner>`'s type extending the one UDDF
gives every person: `personal`, then `contact`, then `equipment`, then
`diveinsurances`. A date goes out widened to midnight, as `date_of_trip`'s do.
"""
diver = self.document.get("diver") or {}
name, email = diver.get("name"), diver.get("email")
name, email, phone, born_on = (diver.get(member) for member in ("name", "email", "phone", "born_on"))
insurances = diver.get("insurances") or []
gear = self.equipment_element()
if not (name or email or gear is not None):
described = bool(name or email or phone or born_on or insurances)
if not (described or gear is not None):
if diver:
self.note(
"diver",
"the document records no name and no email for the logbook's owner, and UDDF's <owner> "
"carries nothing else about a person; no diver is written",
"the document records nothing about the logbook's owner that UDDF's <owner> has an element "
"for; no diver is written",
"dropped",
)
return None
if diver:
self.unmapped("diver", diver, frozenset({"uuid", "name", "email"}))
self.unmapped("diver", diver, frozenset({"uuid", "name", "email", "phone", "born_on", "insurances"}))

element = ET.Element("diver")
uuid = diver.get("uuid")
# A plain `owner` where the document names nobody, which is what every UDDF writer
# in the corpus emits and what the reader is careful never to read as an identity.
owner = _sub(element, "owner", id=_uddf_id("diver", uuid) if uuid and (name or email) else "owner")
# A plain `owner` where the document records nothing about the person this maps,
# which is what every UDDF writer in the corpus emits and what the reader is careful
# never to read as an identity.
owner = _sub(element, "owner", id=_uddf_id("diver", uuid) if uuid and described else "owner")
personal = _sub(owner, "personal")
first, last = _person_names(str(name or ""))
_sub(personal, "firstname", first)
_sub(personal, "lastname", last)
if email:
_sub(_sub(owner, "contact"), "email", str(email))
if born_on:
_sub(_sub(personal, "birthdate"), "datetime", f"{born_on}T00:00:00")
if phone or email:
contact = _sub(owner, "contact")
# `contactType` is a sequence, and `<phone>` comes before `<email>` in it.
if phone:
_sub(contact, "phone", str(phone))
if email:
_sub(contact, "email", str(email))
if gear is not None:
owner.append(gear)
if insurances:
policies = _sub(owner, "diveinsurances")
for index, insurance in enumerate(insurances):
self.unmapped(f"diver/insurances/{index}", insurance, frozenset({"provider", "expires_on"}))
policy = _sub(policies, "insurance")
_sub(policy, "name", str(insurance["provider"]))
if insurance.get("expires_on"):
_sub(_sub(policy, "validdate"), "datetime", f"{insurance['expires_on']}T00:00:00")
return element

# -- computers ---------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/converting.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Converting into DiveJSON

**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing
**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing
here changes what a conforming document is. This document records the rules a converter
follows whatever it is reading, so that they survive being reimplemented: the portable part
of a converter is its rules, not its code, and a port in another language starts here.
Expand Down
4 changes: 2 additions & 2 deletions docs/fit-mapping.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Reading ANT/Garmin FIT into DiveJSON

**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing
**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing
here changes what a conforming document is.

**Several of the messages below have never been read from a device that wrote them.**
Expand Down Expand Up @@ -170,7 +170,7 @@ dive, and a watch that writes one file per dive gives an archive its order.

Every number below was read off real files and off the MIT-licensed global FIT profile that
open decoders carry. **Nothing here comes from Garmin's `Profile.xlsx`** — see the notice in
[`README.md`](../README.md#notices).
[divejson/divejson's `README.md`](https://github.com/divejson/divejson/blob/main/README.md#notices).

### Provenance — `file_id` (0), `device_info` (23), and the file header

Expand Down
2 changes: 1 addition & 1 deletion docs/ssrf-mapping.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Reading Subsurface `.ssrf` into DiveJSON

**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing
**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing
here changes what a conforming document is.

**The rules that hold for every source format are in
Expand Down
2 changes: 1 addition & 1 deletion docs/suunto-json-mapping.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Reading the Suunto app's JSON into DiveJSON

**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing
**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing
here changes what a conforming document is.

**The rules that hold for every source format are in
Expand Down
2 changes: 1 addition & 1 deletion docs/suunto-xml-mapping.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Reading Suunto's DM5 XML into DiveJSON

**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing
**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing
here changes what a conforming document is. The rules every converter follows whatever it
is reading are in [`converting.md`](converting.md) and are **not repeated here** — the note
kinds, identity, the way a zero reads, the representability bound, sample ordering, the
Expand Down
Loading
Loading