diff --git a/gedcom7/__init__.py b/gedcom7/__init__.py index 572ae07..64aa712 100644 --- a/gedcom7/__init__.py +++ b/gedcom7/__init__.py @@ -3,6 +3,7 @@ from importlib.metadata import PackageNotFoundError, version from .exceptions import GedcomError, GedcomParseError, GedcomSerializeError +from .format import format_value from .parser import load, loads from .serializer import dump, dumps @@ -12,6 +13,7 @@ "GedcomSerializeError", "dump", "dumps", + "format_value", "load", "loads", ] diff --git a/gedcom7/format.py b/gedcom7/format.py new file mode 100644 index 0000000..950254d --- /dev/null +++ b/gedcom7/format.py @@ -0,0 +1,352 @@ +"""Format data type values back to payload strings. + +This is the inverse of :mod:`gedcom7.cast`. Every formatter checks its result +against the same grammar production the corresponding cast function matches, so +a value that cannot be written as a conforming payload raises rather than +producing a data stream that will not parse. + +The inverse normalizes: casting discards detail that carries no meaning, such as +the spacing around list separators or a leading zero on a day number, so +``format_value(cast_value(text, type_id), type_id)`` may differ from ``text`` +while denoting the same thing. :class:`~gedcom7.types.PersonalName` is the one +type whose casting is lossy in a way that formatting cannot always undo; see +:func:`_format_personal_name`. +""" + +from __future__ import annotations + +import decimal +import re +from collections.abc import Callable +from typing import Any, TypeVar + +from . import const, grammar, types +from .exceptions import GedcomSerializeError + +_T = TypeVar("_T") + + +def format_value(value: types.DataType | None, type_id: str) -> str | None: + """Format a value as the payload string for its structure type. + + ``None`` and the empty string mean different things, and a caller deciding + what to write has to tell them apart. ``None`` means there is no structure to + write: it comes back for a value of ``None``, and for a false ``Y|``, + which the specification expresses by leaving the structure out rather than by + writing it empty. The empty string means the structure is written with no + payload, as for an empty :class:`~gedcom7.types.DatePeriod`, which is a legal + date period and not the absence of one. + + Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value cannot + be written as a payload conforming to its structure type, including when the + structure type points at a record: a pointer belongs in the structure's + pointer, and writing it as text would escape its leading "@" and turn the + link into a line of text. A structure with no standard type has no data type + to format, so its text is written as it stands rather than passed here. + """ + if value is None: + return None + payload = const.payloads.get(type_id) + if payload is None: + raise GedcomSerializeError(f"Unknown structure type {type_id}") + if not payload: + raise GedcomSerializeError(f"{type_id} takes no payload") + if payload.startswith("@<") and payload.endswith(">@"): + raise GedcomSerializeError( + f"{type_id} points at a record rather than carrying a value; " + "set the structure's pointer instead of its text" + ) + format_function = FORMAT_FUNCTIONS.get(payload) + if not format_function: + return _format_string(value) + return format_function(value) + + +def _expect(value: object, expected: type[_T], type_name: str) -> _T: + """Return the value if it is the type the structure type calls for.""" + if not isinstance(value, expected): + raise GedcomSerializeError( + f"{type_name} takes {expected.__name__}, " + f"not {type(value).__name__}: {value!r}" + ) + return value + + +def _check(text: str, regex: str, type_name: str) -> str: + """Return the text if it conforms to the grammar, and raise if it does not.""" + if re.fullmatch(regex, text) is None: + raise GedcomSerializeError(f"{text!r} is not a valid {type_name} payload") + return text + + +def _format_string(value: object) -> str: + """Format a payload whose data type leaves it an uninterpreted string.""" + return _expect(value, str, "This structure type") + + +def _format_bool(value: object) -> str | None: + """Format a boolean, false being written as no structure at all.""" + return "Y" if _expect(value, bool, "Boolean") else None + + +def _format_integer(value: object) -> str: + """Format a non-negative integer.""" + if isinstance(value, bool): + raise GedcomSerializeError(f"Integer takes int, not bool: {value!r}") + number = _expect(value, int, "Integer") + if number < 0: + raise GedcomSerializeError(f"{number} is not a non-negative integer") + return str(number) + + +def _format_list_text(value: object) -> str: + """Format a list of strings as a comma separated payload. + + The grammar allows space on either side of the separator, so space around an + item carries no meaning and is stripped, as casting strips it. A comma is a + different matter: a list has no escaping mechanism, so an item containing one + cannot be written at all, because reading it back would give two items. + """ + items = [_expect(item, str, "List:Text") for item in _expect(value, list, "List")] + for item in items: + if "," in item: + raise GedcomSerializeError( + f"{item!r} cannot be written as a list item: it contains a comma" + ) + return _check( + ", ".join(item.strip() for item in items), grammar.list_text, "List:Text" + ) + + +def _format_enum(value: object) -> str: + """Format an enumeration value.""" + return _check(_expect(value, str, "Enum"), grammar.enum, "Enum") + + +def _format_list_enum(value: object) -> str: + """Format a list of enumeration values. + + Unlike a list of text, the items are not stripped. An enumeration value may + not contain a space at all, so one that arrives with a space is not a value + needing tidying but a value from outside the vocabulary, and it is refused + here exactly as :func:`_format_enum` refuses it on its own. + """ + items = [_expect(item, str, "List:Enum") for item in _expect(value, list, "List")] + return _check(", ".join(items), grammar.list_enum, "List:Enum") + + +def _format_mediatype(value: object) -> str: + """Format a media type.""" + media_type = _expect(value, types.MediaType, "MediaType") + return _check(media_type.media_type, grammar.mediatype, "MediaType") + + +def _format_tag_definition(value: object) -> str: + """Format a tag definition.""" + definition = _expect(value, types.TagDefinition, "TagDef") + return _check(f"{definition.tag} {definition.uri}", grammar.tagdef, "TagDef") + + +def _format_personal_name(value: object) -> str: + """Format a personal name, the parts taking precedence over the full name. + + ``fullname`` is the payload with its slashes removed, so it cannot say where + they belong and the parts are the only faithful source. A name whose parts + are all absent never carried slashes, and is written as it stands. Where both + disagree the parts win, which is the one place this module cannot reproduce + its input: a surname that also occurs in the given name is unrecoverable. + """ + name = _expect(value, types.PersonalName, "PersonalName") + if name.given is None and name.surname is None and name.suffix is None: + return _check(name.fullname, grammar.personalname, "PersonalName") + text = ( + (f"{name.given} " if name.given else "") + + f"/{name.surname or ''}/" + + (f" {name.suffix}" if name.suffix else "") + ) + return _check(text, grammar.personalname, "PersonalName") + + +def _format_time(value: object) -> str: + """Format a time, the hour and minute padded to the conventional two digits.""" + time = _expect(value, types.Time, "Time") + text = f"{time.hour:02d}:{time.minute:02d}" + if time.second is None and time.fraction is not None: + raise GedcomSerializeError("a Time with a fraction must have seconds") + if time.second is not None: + text += f":{time.second:02d}" + if time.fraction is not None: + text += f".{time.fraction}" + if time.tz is not None: + text += time.tz + return _check(text, grammar.time, "Time") + + +def _format_age(value: object) -> str: + """Format an age, in the years, months, weeks, days order the grammar fixes.""" + age = _expect(value, types.Age, "Age") + units = ((age.years, "y"), (age.months, "m"), (age.weeks, "w"), (age.days, "d")) + parts = [f"{number}{unit}" for number, unit in units if number is not None] + if not parts: + # unlike an empty DatePeriod, an age with no duration has no valid payload + raise GedcomSerializeError( + "an Age must have at least one of years, months, weeks or days" + ) + if age.agebound is not None: + parts.insert(0, age.agebound) + return _check(" ".join(parts), grammar.age, "Age") + + +def _format_degrees(degrees: float) -> str: + """Format a coordinate's magnitude in the fixed notation the grammar requires. + + ``str`` renders small magnitudes in exponent notation, which no coordinate + payload may contain, so the number is rendered through :class:`~decimal.Decimal` + instead. + + A trailing zero after the decimal point comes from the float rather than from + the coordinate, and casting has already discarded which of "N90" and "N90.0" + was written, so the shorter spelling is chosen for both. Dropping it also + keeps a whole number of degrees from depending on whether the caller happened + to hold it as an int or a float. + """ + text = format(decimal.Decimal(repr(abs(degrees))), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + + +def _expect_degrees(value: object, type_name: str, limit: int) -> float: + """Return the value if it is a number within the coordinate's range.""" + if isinstance(value, bool) or not isinstance(value, int | float): + raise GedcomSerializeError( + f"{type_name} takes a number, not {type(value).__name__}: {value!r}" + ) + if not -limit <= value <= limit: + raise GedcomSerializeError( + f"{value} is not a {type_name.lower()} between -{limit} and {limit}" + ) + return value + + +def _format_latitude(value: object) -> str: + """Format a latitude in signed decimal degrees, north positive.""" + degrees = _expect_degrees(value, "Latitude", 90) + direction = "S" if degrees < 0 else "N" + return _check(direction + _format_degrees(degrees), grammar.latitude, "Latitude") + + +def _format_longitude(value: object) -> str: + """Format a longitude in signed decimal degrees, east positive.""" + degrees = _expect_degrees(value, "Longitude", 180) + direction = "W" if degrees < 0 else "E" + return _check(direction + _format_degrees(degrees), grammar.longitude, "Longitude") + + +def _date_text(date: types.Date) -> str: + """Build the text of a date, for use on its own or inside a compound date.""" + date = _expect(date, types.Date, "Date") + if date.year is None: + raise GedcomSerializeError("a Date must have a year") + if date.day is not None and date.month is None: + raise GedcomSerializeError("a Date with a day must have a month") + parts = [] + if date.calendar is not None: + parts.append(date.calendar) + if date.day is not None: + parts.append(str(date.day)) + if date.month is not None: + parts.append(date.month) + parts.append(str(date.year)) + if date.epoch is not None: + parts.append(date.epoch) + return " ".join(parts) + + +def _format_date(value: object) -> str: + """Format a date.""" + return _check(_date_text(_expect(value, types.Date, "Date")), grammar.date, "Date") + + +def _format_date_exact(value: object) -> str: + """Format an exact date, whose day, month and year are all required.""" + date = _expect(value, types.DateExact, "DateExact") + return _check( + f"{date.day} {date.month} {date.year}", grammar.dateexact, "DateExact" + ) + + +def _format_date_approx(value: object) -> str: + """Format an approximate date.""" + approx = _expect(value, types.DateApprox, "DateApprox") + if approx.approx is None: + raise GedcomSerializeError( + "a DateApprox must have a qualifier of ABT, CAL or EST" + ) + return _check( + f"{approx.approx} {_date_text(approx.date)}", grammar.dateapprox, "DateApprox" + ) + + +def _format_date_range(value: object) -> str: + """Format a date range as one of its BET/AND, AFT or BEF forms.""" + date_range = _expect(value, types.DateRange, "DateRange") + if date_range.start is not None and date_range.end is not None: + text = f"BET {_date_text(date_range.start)} AND {_date_text(date_range.end)}" + elif date_range.start is not None: + text = f"AFT {_date_text(date_range.start)}" + elif date_range.end is not None: + text = f"BEF {_date_text(date_range.end)}" + else: + raise GedcomSerializeError("a DateRange must have a start or an end") + return _check(text, grammar.daterange, "DateRange") + + +def _format_date_period(value: object) -> str: + """Format a date period, an empty period being a legal empty payload.""" + period = _expect(value, types.DatePeriod, "DatePeriod") + if period.from_ is not None and period.to is not None: + text = f"FROM {_date_text(period.from_)} TO {_date_text(period.to)}" + elif period.from_ is not None: + text = f"FROM {_date_text(period.from_)}" + elif period.to is not None: + text = f"TO {_date_text(period.to)}" + else: + text = "" + return _check(text, grammar.dateperiod, "DatePeriod") + + +def _format_date_value(value: object) -> str: + """Format whichever of the date value forms the value carries.""" + if isinstance(value, types.DateApprox): + return _format_date_approx(value) + if isinstance(value, types.DateRange): + return _format_date_range(value) + if isinstance(value, types.DatePeriod): + return _format_date_period(value) + return _format_date(value) + + +# Mirrors cast.CAST_FUNCTIONS: a None entry marks a payload that is carried as an +# uninterpreted string in both directions. +FORMAT_FUNCTIONS: dict[str, Callable[[Any], str | None] | None] = { + "Y|": _format_bool, + "http://www.w3.org/2001/XMLSchema#Language": None, + "http://www.w3.org/2001/XMLSchema#anyURI": None, + "http://www.w3.org/2001/XMLSchema#nonNegativeInteger": _format_integer, + "http://www.w3.org/2001/XMLSchema#string": None, + "http://www.w3.org/ns/dcat#mediaType": _format_mediatype, + "https://gedcom.io/terms/v7/type-Age": _format_age, + "https://gedcom.io/terms/v7/type-Date": _format_date_value, + "https://gedcom.io/terms/v7/type-Date#exact": _format_date_exact, + "https://gedcom.io/terms/v7/type-Date#period": _format_date_period, + "https://gedcom.io/terms/v7/type-Enum": _format_enum, + "https://gedcom.io/terms/v7/type-FilePath": None, + "https://gedcom.io/terms/v7/type-Latitude": _format_latitude, + "https://gedcom.io/terms/v7/type-List#Enum": _format_list_enum, + "https://gedcom.io/terms/v7/type-List#Text": _format_list_text, + "https://gedcom.io/terms/v7/type-Longitude": _format_longitude, + "https://gedcom.io/terms/v7/type-Name": _format_personal_name, + "https://gedcom.io/terms/v7/type-TagDef": _format_tag_definition, + "https://gedcom.io/terms/v7/type-Time": _format_time, +} diff --git a/test/test_format.py b/test/test_format.py new file mode 100644 index 0000000..f9f7258 --- /dev/null +++ b/test/test_format.py @@ -0,0 +1,630 @@ +"""Tests for formatting data type values back to payload strings.""" + +import pathlib + +import pytest + +import gedcom7 +from gedcom7 import GedcomSerializeError, const, format, types + +V7 = "https://gedcom.io/terms/v7/" +LATI = "https://gedcom.io/terms/v7/LATI" +TIME = "https://gedcom.io/terms/v7/TIME" +ABBR = "https://gedcom.io/terms/v7/ABBR" +ADOP = "https://gedcom.io/terms/v7/ADOP" +BAPL = "https://gedcom.io/terms/v7/BAPL" + + +# -------------------------------------------------------------------------- +# Coordinates: the payload has no exponent notation and no sign +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("degrees", "expected"), + [ + (18.150944, "N18.150944"), + (-18.150944, "S18.150944"), + # str() would render these as "1e-05" and "1.5e-07", which no coordinate + # payload may contain + (1e-05, "N0.00001"), + (-1e-05, "S0.00001"), + (1.5e-07, "N0.00000015"), + (51.5, "N51.5"), + # casting returns a float even for a whole number of degrees, so the + # spelling must not depend on which of the two the caller holds + (0, "N0"), + (0.0, "N0"), + (-0.0, "N0"), + (90, "N90"), + (90.0, "N90"), + (-90, "S90"), + (-90.0, "S90"), + (51.0, "N51"), + ], +) +def test_format_latitude(degrees: float, expected: str) -> None: + assert format._format_latitude(degrees) == expected + + +@pytest.mark.parametrize( + ("degrees", "expected"), + [ + (168.150944, "E168.150944"), + (-168.150944, "W168.150944"), + (-1e-05, "W0.00001"), + (180, "E180"), + (180.0, "E180"), + (-180, "W180"), + (-180.0, "W180"), + (0.0, "E0"), + (168.0, "E168"), + ], +) +def test_format_longitude(degrees: float, expected: str) -> None: + assert format._format_longitude(degrees) == expected + + +@pytest.mark.parametrize("degrees", [90.5, -90.5, 91, 1000, float("inf")]) +def test_format_latitude_out_of_range(degrees: float) -> None: + """The grammar admits "N90.5", so the range is enforced here instead.""" + with pytest.raises(GedcomSerializeError): + format._format_latitude(degrees) + + +@pytest.mark.parametrize("degrees", [180.5, -180.5, 181]) +def test_format_longitude_out_of_range(degrees: float) -> None: + with pytest.raises(GedcomSerializeError): + format._format_longitude(degrees) + + +@pytest.mark.parametrize("value", ["18.15", True, None]) +def test_format_latitude_rejects_non_numbers(value: object) -> None: + with pytest.raises(GedcomSerializeError): + format._format_latitude(value) + + +# -------------------------------------------------------------------------- +# Time +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("time", "expected"), + [ + (types.Time(hour=13, minute=15), "13:15"), + # the grammar admits a one digit hour; two is the conventional form + (types.Time(hour=8, minute=38), "08:38"), + (types.Time(hour=0, minute=0), "00:00"), + (types.Time(hour=13, minute=15, second=2), "13:15:02"), + (types.Time(hour=13, minute=15, second=12, fraction="246"), "13:15:12.246"), + # a leading zero in the fraction survives, a trailing zero is not added + (types.Time(hour=13, minute=15, second=12, fraction="05"), "13:15:12.05"), + (types.Time(hour=13, minute=15, second=12, fraction="5"), "13:15:12.5"), + (types.Time(hour=13, minute=15, second=12, fraction="500"), "13:15:12.500"), + (types.Time(hour=13, minute=15, tz="Z"), "13:15Z"), + ( + types.Time(hour=13, minute=15, second=12, fraction="48", tz="Z"), + "13:15:12.48Z", + ), + ], +) +def test_format_time(time: types.Time, expected: str) -> None: + assert format._format_time(time) == expected + + +def test_format_time_fraction_without_seconds() -> None: + """The grammar hangs the fraction off the seconds, so it cannot stand alone.""" + with pytest.raises(GedcomSerializeError): + format._format_time(types.Time(hour=13, minute=15, fraction="5")) + + +@pytest.mark.parametrize( + "time", [types.Time(hour=24, minute=0), types.Time(hour=1, minute=60)] +) +def test_format_time_out_of_range(time: types.Time) -> None: + with pytest.raises(GedcomSerializeError): + format._format_time(time) + + +# -------------------------------------------------------------------------- +# PersonalName: the one type whose casting formatting cannot always undo +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + # no parts at all: the name never carried slashes, so it stands as it is + (types.PersonalName(fullname="Aka"), "Aka"), + (types.PersonalName(fullname="Immigrant Name"), "Immigrant Name"), + ( + types.PersonalName(fullname="John Doe", given="John", surname="Doe"), + "John /Doe/", + ), + ( + types.PersonalName( + fullname="John Doe Jr.", given="John", surname="Doe", suffix="Jr." + ), + "John /Doe/ Jr.", + ), + # each part may be absent on its own + (types.PersonalName(fullname="Doe", surname="Doe"), "/Doe/"), + (types.PersonalName(fullname="John", given="John"), "John //"), + (types.PersonalName(fullname="Jr.", suffix="Jr."), "// Jr."), + ], +) +def test_format_personal_name(name: types.PersonalName, expected: str) -> None: + assert format._format_personal_name(name) == expected + + +def test_format_personal_name_prefers_parts_over_fullname() -> None: + """fullname is the payload with its slashes removed, so it cannot place them.""" + name = types.PersonalName(fullname="ignored entirely", given="John", surname="Doe") + assert format._format_personal_name(name) == "John /Doe/" + + +# -------------------------------------------------------------------------- +# Age: unlike an empty DatePeriod, an empty Age has no valid payload +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("age", "expected"), + [ + (types.Age(years=25), "25y"), + (types.Age(days=8), "8d"), + (types.Age(years=25, months=3, weeks=2, days=1), "25y 3m 2w 1d"), + (types.Age(agebound=">", years=25, months=3), "> 25y 3m"), + (types.Age(agebound="<", days=8), "< 8d"), + (types.Age(years=0), "0y"), + ], +) +def test_format_age(age: types.Age, expected: str) -> None: + assert format._format_age(age) == expected + + +@pytest.mark.parametrize("age", [types.Age(), types.Age(agebound=">")]) +def test_format_age_without_duration(age: types.Age) -> None: + with pytest.raises(GedcomSerializeError): + format._format_age(age) + + +# -------------------------------------------------------------------------- +# Dates +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("date", "expected"), + [ + (types.Date(year=2000), "2000"), + (types.Date(month="JAN", year=2000), "JAN 2000"), + (types.Date(day=1, month="JAN", year=2000), "1 JAN 2000"), + ( + types.Date(calendar="JULIAN", day=1, month="JAN", year=2000), + "JULIAN 1 JAN 2000", + ), + (types.Date(year=44, epoch="BCE"), "44 BCE"), + (types.Date(day=1, month="JAN", year=44, epoch="BCE"), "1 JAN 44 BCE"), + ], +) +def test_format_date(date: types.Date, expected: str) -> None: + assert format._format_date(date) == expected + + +@pytest.mark.parametrize( + "date", [types.Date(), types.Date(month="JAN"), types.Date(day=1, year=2000)] +) +def test_format_date_incomplete(date: types.Date) -> None: + """A date needs a year, and a day is meaningless without a month.""" + with pytest.raises(GedcomSerializeError): + format._format_date(date) + + +def test_format_date_period_empty_is_a_legal_payload() -> None: + """The grammar makes every part of a date period optional.""" + assert format._format_date_period(types.DatePeriod()) == "" + + +@pytest.mark.parametrize( + ("period", "expected"), + [ + (types.DatePeriod(from_=types.Date(year=1700)), "FROM 1700"), + (types.DatePeriod(to=types.Date(year=1800)), "TO 1800"), + ( + types.DatePeriod(from_=types.Date(year=1700), to=types.Date(year=1800)), + "FROM 1700 TO 1800", + ), + ], +) +def test_format_date_period(period: types.DatePeriod, expected: str) -> None: + assert format._format_date_period(period) == expected + + +@pytest.mark.parametrize( + ("date_range", "expected"), + [ + (types.DateRange(start=types.Date(year=1700)), "AFT 1700"), + (types.DateRange(end=types.Date(year=1800)), "BEF 1800"), + ( + types.DateRange(start=types.Date(year=1700), end=types.Date(year=1800)), + "BET 1700 AND 1800", + ), + ], +) +def test_format_date_range(date_range: types.DateRange, expected: str) -> None: + assert format._format_date_range(date_range) == expected + + +def test_format_date_range_empty() -> None: + with pytest.raises(GedcomSerializeError): + format._format_date_range(types.DateRange()) + + +@pytest.mark.parametrize("qualifier", ["ABT", "CAL", "EST"]) +def test_format_date_approx(qualifier: str) -> None: + approx = types.DateApprox( + date=types.Date(day=1, month="OCT", year=2023), approx=qualifier + ) + assert format._format_date_approx(approx) == f"{qualifier} 1 OCT 2023" + + +def test_format_date_approx_without_qualifier() -> None: + with pytest.raises(GedcomSerializeError): + format._format_date_approx(types.DateApprox(date=types.Date(year=2023))) + + +def test_format_date_exact() -> None: + assert format._format_date_exact( + types.DateExact(day=1, month="NOV", year=2022) + ) == ("1 NOV 2022") + + +def test_format_date_value_dispatches_on_the_form() -> None: + """A date value is whichever of the four forms the value carries.""" + assert format._format_date_value(types.Date(year=1998)) == "1998" + assert ( + format._format_date_value(types.DatePeriod(to=types.Date(year=1800))) + == "TO 1800" + ) + assert ( + format._format_date_value(types.DateRange(end=types.Date(year=1800))) + == "BEF 1800" + ) + assert ( + format._format_date_value( + types.DateApprox(date=types.Date(year=1800), approx="ABT") + ) + == "ABT 1800" + ) + + +# -------------------------------------------------------------------------- +# Scalars and lists +# -------------------------------------------------------------------------- + + +def test_format_bool() -> None: + """A false boolean is written by leaving the structure out altogether.""" + assert format._format_bool(True) == "Y" + assert format._format_bool(False) is None + + +@pytest.mark.parametrize("value", [0, 1, "Y", None]) +def test_format_bool_rejects_non_bools(value: object) -> None: + with pytest.raises(GedcomSerializeError): + format._format_bool(value) + + +def test_format_integer() -> None: + assert format._format_integer(0) == "0" + assert format._format_integer(100) == "100" + + +@pytest.mark.parametrize("value", [-1, True, 1.5, "1"]) +def test_format_integer_rejects(value: object) -> None: + """The payload is a non-negative integer, and a bool is not an integer here.""" + with pytest.raises(GedcomSerializeError): + format._format_integer(value) + + +def test_format_list_text() -> None: + assert ( + format._format_list_text(["City", "County", "State"]) == "City, County, State" + ) + assert format._format_list_text(["Somewhere"]) == "Somewhere" + + +def test_format_list_text_item_containing_a_comma() -> None: + """A list has no escaping, so a comma in an item would split it in two.""" + with pytest.raises(GedcomSerializeError): + format._format_list_text(["Paris, France", "Europe"]) + + +def test_format_list_text_strips_space_around_items() -> None: + """The grammar allows space either side of the separator, so it means nothing.""" + assert format._format_list_text(["City ", " County"]) == "City, County" + assert format._format_list_text([" Somewhere "]) == "Somewhere" + + +def test_format_list_enum() -> None: + assert format._format_list_enum(["BIRT", "DEAT"]) == "BIRT, DEAT" + + +def test_format_list_enum_does_not_strip_items() -> None: + """An enum cannot contain a space, so a spaced item is outside the vocabulary. + + A list of text is stripped instead, because there the space really is only + padding around a delimiter. + """ + with pytest.raises(GedcomSerializeError): + format._format_list_enum([" BIRT", "DEAT"]) + with pytest.raises(GedcomSerializeError): + format._format_enum(" BIRT") + + +def test_format_enum() -> None: + assert format._format_enum("ADOPTED") == "ADOPTED" + assert format._format_enum("0") == "0" + assert format._format_enum("_CUSTOM") == "_CUSTOM" + + +def test_format_enum_invalid() -> None: + with pytest.raises(GedcomSerializeError): + format._format_enum("not an enum") + + +def test_format_mediatype() -> None: + assert ( + format._format_mediatype(types.MediaType(media_type="text/plain")) + == "text/plain" + ) + + +def test_format_mediatype_invalid() -> None: + with pytest.raises(GedcomSerializeError): + format._format_mediatype(types.MediaType(media_type="nonsense")) + + +def test_format_tag_definition() -> None: + definition = types.TagDefinition( + tag="_SKYPEID", uri="http://xmlns.com/foaf/0.1/skypeID" + ) + assert format._format_tag_definition(definition) == ( + "_SKYPEID http://xmlns.com/foaf/0.1/skypeID" + ) + + +def test_format_tag_definition_invalid_tag() -> None: + """An extension tag begins with an underscore.""" + with pytest.raises(GedcomSerializeError): + format._format_tag_definition( + types.TagDefinition(tag="SKYPEID", uri="http://x/") + ) + + +# -------------------------------------------------------------------------- +# format_value dispatch +# -------------------------------------------------------------------------- + + +def test_format_value_dispatches_by_structure_type() -> None: + assert gedcom7.format_value(18.150944, LATI) == "N18.150944" + assert gedcom7.format_value(types.Time(hour=8, minute=38), TIME) == "08:38" + assert gedcom7.format_value("free text", ABBR) == "free text" + + +def test_format_value_of_none() -> None: + assert gedcom7.format_value(None, ABBR) is None + + +def test_format_value_of_false_is_none() -> None: + """The caller drops the structure rather than writing an empty payload.""" + assert gedcom7.format_value(True, ADOP) == "Y" + assert gedcom7.format_value(False, ADOP) is None + + +def test_format_value_unknown_structure_type() -> None: + with pytest.raises(GedcomSerializeError): + gedcom7.format_value("x", "https://example.com/not-a-structure-type") + + +def test_format_value_structure_taking_no_payload() -> None: + with pytest.raises(GedcomSerializeError): + gedcom7.format_value("x", BAPL) + + +@pytest.mark.parametrize("type_id", [V7 + "ALIA", V7 + "FAMS", V7 + "HUSB"]) +def test_format_value_structure_pointing_at_a_record(type_id: str) -> None: + """A pointer written as text would be escaped, turning a link into a string.""" + with pytest.raises(GedcomSerializeError): + gedcom7.format_value("@I1@", type_id) + + +def test_every_pointer_structure_type_is_refused() -> None: + """No structure type pointing at a record may fall through to plain text.""" + for type_id, payload in const.payloads.items(): + if payload.startswith("@<"): + with pytest.raises(GedcomSerializeError): + gedcom7.format_value("@I1@", type_id) + + +def test_every_payload_in_the_specification_is_accounted_for() -> None: + """No kind of payload may reach format_value without a deliberate decision. + + The fallback treats a payload it does not recognise as plain text. That is + right for the string-like types and was silently wrong for the pointer types, + which reached it for as long as nothing enumerated what the specification + actually contains. Partitioning the whole table makes a payload kind added by + a later version of the specification fail here rather than fall through. + """ + for payload in set(const.payloads.values()): + accounted_for = ( + payload == "" + or payload in format.FORMAT_FUNCTIONS + or (payload.startswith("@<") and payload.endswith(">@")) + ) + assert accounted_for, f"{payload} would fall through to plain text" + + +def test_format_value_empty_payload_is_not_no_payload() -> None: + """An empty date period is a payload, and is not the same as None.""" + empty = gedcom7.format_value(types.DatePeriod(), V7 + "NO-DATE") + assert empty == "" + assert empty is not None + + +def test_format_value_wrong_type_for_the_structure() -> None: + with pytest.raises(GedcomSerializeError): + gedcom7.format_value("13:15", TIME) + + +def test_format_functions_mirror_cast_functions() -> None: + """Both tables key off the payload type, so they must cover the same set.""" + from gedcom7 import cast + + assert format.FORMAT_FUNCTIONS.keys() == cast.CAST_FUNCTIONS.keys() + for payload, cast_function in cast.CAST_FUNCTIONS.items(): + assert (cast_function is None) == (format.FORMAT_FUNCTIONS[payload] is None) + + +# -------------------------------------------------------------------------- +# Value level round trip: the invariant a text level round trip cannot state +# -------------------------------------------------------------------------- + + +# One structure type per data type, so that going through the public entry +# points covers every payload the two tables know about. Kept honest by +# test_round_trip_covers_every_data_type below. +ROUND_TRIP = [ + (V7 + "ADOP", "Y"), + (V7 + "LANG", "en-US"), + (V7 + "EXID-TYPE", "http://example.com/exid"), + (V7 + "HEIGHT", "100"), + (V7 + "HEIGHT", "0"), + (V7 + "ABBR", "any text at all, commas and / included"), + (V7 + "FORM", "text/plain"), + (V7 + "AGE", "> 25y 3m 2w 1d"), + (V7 + "AGE", "8d"), + (V7 + "DATE", "1998"), + (V7 + "DATE", "FROM 1700 TO 1800"), + (V7 + "DATE", "BET 1 JAN 2000 AND 31 DEC 2000"), + (V7 + "DATE", "ABT 1 OCT 2023"), + (V7 + "DATE", "JULIAN 1 JAN 44 BCE"), + (V7 + "DATE-exact", "1 NOV 2022"), + (V7 + "NO-DATE", "FROM 1700 TO 1800"), + (V7 + "NO-DATE", "TO 1800"), + (V7 + "FAMC-ADOP", "BOTH"), + (V7 + "FILE", "media/original.mp3"), + (V7 + "LATI", "N18.150944"), + (V7 + "LATI", "S0.00001"), + # a whole number of degrees casts to a float, and must not come back "N90.0" + (V7 + "LATI", "N90"), + (V7 + "LATI", "N0"), + (V7 + "LATI", "S51"), + (V7 + "LONG", "E168.150944"), + (V7 + "LONG", "W0.00001"), + (V7 + "LONG", "E180"), + (V7 + "DATA-EVEN", "BIRT, DEAT"), + (V7 + "PLAC-FORM", "City, County, State"), + (V7 + "INDI-NAME", "John /Doe/ Jr."), + (V7 + "INDI-NAME", "/Doe/"), + (V7 + "INDI-NAME", "Aka"), + (V7 + "TAG", "_SKYPEID http://xmlns.com/foaf/0.1/skypeID"), + (V7 + "TIME", "13:15:12.05Z"), + (V7 + "TIME", "13:15:12.500"), +] + + +@pytest.mark.parametrize(("type_id", "text"), ROUND_TRIP) +def test_formatting_is_the_inverse_of_casting(type_id: str, text: str) -> None: + """Formatting a cast payload gives the payload back.""" + assert gedcom7.format_value(gedcom7.cast.cast_value(text, type_id), type_id) == text + + +@pytest.mark.parametrize(("type_id", "text"), ROUND_TRIP) +def test_casting_is_the_inverse_of_formatting(type_id: str, text: str) -> None: + """Casting a formatted value gives the value back. + + This is the direction that is exact for every value that came from casting a + payload, PersonalName included: casting is what discards detail, so once a + value has been through it the pair is a true inverse. The empty payload is + the exception, and test_empty_payload_does_not_round_trip pins it. + """ + value = gedcom7.cast.cast_value(text, type_id) + formatted = gedcom7.format_value(value, type_id) + assert formatted is not None + assert gedcom7.cast.cast_value(formatted, type_id) == value + + +def test_empty_payload_does_not_round_trip() -> None: + """Casting maps every empty payload to None, whatever data type it belongs to. + + So an empty DatePeriod, which is a legal date period, formats to "" and casts + back to None rather than to DatePeriod(). Nothing is lost by it: a structure + written with an empty payload and one written with no payload are the same + line, so the two spellings are not distinguishable in a data stream to begin + with. Preserving them instead would mean casting an empty Y| to False, + which would read "1 DEAT" with no payload as an assertion that the death did + not happen, the opposite of what it means. + """ + assert gedcom7.format_value(types.DatePeriod(), V7 + "NO-DATE") == "" + assert gedcom7.cast.cast_value("", V7 + "NO-DATE") is None + assert gedcom7.cast.cast_value("", V7 + "DEAT") is None + + +def test_round_trip_covers_every_data_type() -> None: + """Every payload either table knows about must appear in the round trip.""" + covered = {const.payloads[type_id] for type_id, _ in ROUND_TRIP} + assert covered == set(gedcom7.cast.CAST_FUNCTIONS) + assert covered == set(format.FORMAT_FUNCTIONS) + + +# -------------------------------------------------------------------------- +# Corpus sweep +# -------------------------------------------------------------------------- + +# Payloads in the corpus that formatting writes differently from how they were +# read. Each denotes the same value; the grammar simply admits more than one +# spelling, and formatting picks the conventional one. +CORPUS_NORMALIZATIONS = { + # the grammar admits a one digit hour + ("https://gedcom.io/terms/v7/type-Time", "8:38"): "08:38", +} + + +def test_every_corpus_payload_round_trips() -> None: + """Format every text payload in maximal70.ged and compare with the original. + + Structures with no text are left out, which is not the same as leaving out + nothing: most of them point at a record rather than carrying a value, and + format_value refuses those by design. The rest have an empty payload, which + casting maps to None, so there is no value to format and nothing to compare. + """ + filename = pathlib.Path(__file__).parent / "data" / "maximal70.ged" + records = gedcom7.loads(filename.read_text(encoding="utf-8")) + + formatted = 0 + unexpected = [] + + def visit(structure: types.GedcomStructure) -> None: + nonlocal formatted + type_id = structure.type_id + if type_id is not None and structure.text and const.payloads.get(type_id): + expected = CORPUS_NORMALIZATIONS.get( + (const.payloads[type_id], structure.text), structure.text + ) + actual = gedcom7.format_value(structure.value, type_id) + formatted += 1 + if actual != expected: + unexpected.append((structure.tag, type_id, structure.text, actual)) + for child in structure.children: + visit(child) + + for record in records: + visit(record) + + assert not unexpected + # a guard against the sweep quietly stopping to visit anything + assert formatted > 600