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
3 changes: 2 additions & 1 deletion gedcom7/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from importlib.metadata import PackageNotFoundError, version

from .exceptions import GedcomError, GedcomParseError, GedcomSerializeError
from .format import format_value
from .format import format_value, set_value
from .parser import load, loads
from .serializer import dump, dumps

Expand All @@ -16,6 +16,7 @@
"format_value",
"load",
"loads",
"set_value",
]

try:
Expand Down
46 changes: 46 additions & 0 deletions gedcom7/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,52 @@ def format_value(value: types.DataType | None, type_id: str) -> str | None:
return format_function(value)


def set_value(
structure: types.GedcomStructure,
value: types.DataType,
type_id: str | None = None,
) -> None:
"""Set a structure's payload from a value of its structure type's data type.

The counterpart of :attr:`~gedcom7.types.GedcomStructure.value`, which reads
a payload back. Reading is a property of a structure the parser has already
built; writing is something a writer does to one, which is why this is a
function here rather than a method there.

Which data type applies follows from the structure type, and a structure only
has one once it sits under its superstructure, since that is what gives its
tag a meaning. Building a tree from the leaves up therefore means attaching a
structure before setting its value, or naming the structure type here. Where
no standard type applies the payload is carried uninterpreted, exactly as the
reader returns it, so a string is set as it stands and anything else is
refused.

Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value cannot
be written as a payload, which includes a false ``Y|<NULL>``: the
specification expresses that by leaving the structure out, and removing a
structure from its superstructure is the caller's to do.
"""
resolved = type_id if type_id is not None else structure.type_id
if resolved is None:
if not isinstance(value, str):
raise GedcomSerializeError(
f"no standard structure type applies to {structure.tag}, so a "
f"{type(value).__name__} cannot be formatted for it. Attach the "
"structure to its superstructure before setting a value, or name "
"the structure type with type_id"
)
structure.text = value
return
formatted = format_value(value, resolved)
if formatted is None:
raise GedcomSerializeError(
f"{value!r} is written by leaving the structure out rather than by "
f"giving it a payload, so it cannot be set on {structure.tag}; "
"remove the structure from its superstructure instead"
)
structure.text = formatted


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):
Expand Down
6 changes: 3 additions & 3 deletions gedcom7/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ class GedcomStructure:

tag: str
# absent for a line without a pointer payload / without a cross-reference id
pointer: str | None
text: str
xref: str | None
pointer: str | None = None
text: str = ""
xref: str | None = None
children: list[GedcomStructure] = field(default_factory=list)
# Excluded from comparison and repr: it points back up the tree, so including
# it would make __eq__ recurse endlessly and __repr__ print every ancestor.
Expand Down
110 changes: 110 additions & 0 deletions test/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,113 @@ def visit(structure: types.GedcomStructure) -> None:
assert not unexpected
# a guard against the sweep quietly stopping to visit anything
assert formatted > 600


# --------------------------------------------------------------------------
# set_value
# --------------------------------------------------------------------------


def test_set_value_uses_the_superstructure_to_find_the_data_type() -> None:
"""R2: an attached structure knows its own structure type."""
individual = types.GedcomStructure(tag="INDI", xref="@I1@")
birth = types.GedcomStructure(tag="BIRT")
individual.append_child(birth)
date = types.GedcomStructure(tag="DATE")
birth.append_child(date)

gedcom7.set_value(date, types.Date(day=1, month="JAN", year=2000))
assert date.text == "1 JAN 2000"
assert date.value == types.Date(day=1, month="JAN", year=2000)


def test_set_value_accepts_an_explicit_structure_type() -> None:
"""R1: naming the structure type works before the structure is attached."""
date = types.GedcomStructure(tag="DATE")
gedcom7.set_value(
date,
types.Date(day=1, month="JAN", year=2000),
type_id="https://gedcom.io/terms/v7/DATE",
)
assert date.text == "1 JAN 2000"


def test_set_value_explicit_type_id_wins_over_the_superstructure() -> None:
individual = types.GedcomStructure(tag="INDI", xref="@I1@")
child = types.GedcomStructure(tag="DATE")
individual.append_child(child)
gedcom7.set_value(
child, types.Time(hour=13, minute=15), type_id="https://gedcom.io/terms/v7/TIME"
)
assert child.text == "13:15"


def test_set_value_on_an_unattached_structure_says_so() -> None:
"""V5: the common way to get this wrong is to build the tree upwards."""
date = types.GedcomStructure(tag="DATE")
assert date.type_id is None
with pytest.raises(gedcom7.GedcomSerializeError, match="superstructure"):
gedcom7.set_value(date, types.Date(day=1, month="JAN", year=2000))


def test_set_value_of_a_string_where_no_standard_type_applies() -> None:
"""V4: an extension payload is carried as it stands, as the getter returns it."""
structure = types.GedcomStructure(tag="_MYTAG")
assert structure.type_id is None
gedcom7.set_value(structure, "whatever the extension means")
assert structure.text == "whatever the extension means"
assert structure.value == "whatever the extension means"


def test_set_value_of_false_cannot_empty_the_payload() -> None:
"""V2: the specification writes a false Y|<NULL> by omitting the structure."""
individual = types.GedcomStructure(tag="INDI", xref="@I1@")
death = types.GedcomStructure(tag="DEAT")
individual.append_child(death)

gedcom7.set_value(death, True)
assert death.text == "Y"
with pytest.raises(gedcom7.GedcomSerializeError, match="leaving the structure out"):
gedcom7.set_value(death, False)


def test_set_value_propagates_a_value_that_cannot_be_written() -> None:
"""V3: format_value does the validating, and its refusals reach the caller."""
individual = types.GedcomStructure(tag="INDI", xref="@I1@")
birth = types.GedcomStructure(tag="BIRT")
individual.append_child(birth)
date = types.GedcomStructure(tag="DATE")
birth.append_child(date)

with pytest.raises(gedcom7.GedcomSerializeError):
gedcom7.set_value(date, types.Date(day=1, year=2000))


def test_set_value_round_trips_through_the_serializer() -> None:
"""A tree built with set_value alone serializes and parses back unchanged."""
head = types.GedcomStructure(tag="HEAD")
gedc = types.GedcomStructure(tag="GEDC")
head.append_child(gedc)
vers = types.GedcomStructure(tag="VERS")
gedc.append_child(vers)
gedcom7.set_value(vers, "7.0")

individual = types.GedcomStructure(tag="INDI", xref="@I1@")
name = types.GedcomStructure(tag="NAME")
individual.append_child(name)
gedcom7.set_value(
name, types.PersonalName(fullname="John Doe", given="John", surname="Doe")
)
birth = types.GedcomStructure(tag="BIRT")
individual.append_child(birth)
date = types.GedcomStructure(tag="DATE")
birth.append_child(date)
gedcom7.set_value(date, types.Date(day=1, month="JAN", year=2000))

trlr = types.GedcomStructure(tag="TRLR")
text = gedcom7.dumps([head, individual, trlr], byte_order_mark=False)
assert text == (
"0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 NAME John /Doe/\n"
"1 BIRT\n2 DATE 1 JAN 2000\n0 TRLR\n"
)
assert gedcom7.loads(text) == [head, individual, trlr]
29 changes: 29 additions & 0 deletions test/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,32 @@ def test_parent_links() -> None:
assert indi.parent is None
assert indi.children[0].parent is indi
assert indi.children[0].children[0].parent is indi.children[0]


# --------------------------------------------------------------------------
# Constructor defaults
# --------------------------------------------------------------------------


def test_structure_needs_only_a_tag() -> None:
"""A writer builds these by the thousand, so only the tag is required."""
structure = types.GedcomStructure(tag="GIVN")
assert structure.pointer is None
assert structure.text == ""
assert structure.xref is None
assert structure.children == []
assert structure.parent is None


def test_structure_still_takes_every_field_positionally() -> None:
"""Defaulting the fields must not move them, so old calls keep working."""
structure = types.GedcomStructure("NAME", None, "John /Doe/", None)
assert structure == types.GedcomStructure(
tag="NAME", pointer=None, text="John /Doe/", xref=None
)


def test_structure_children_are_not_shared_between_instances() -> None:
first = types.GedcomStructure(tag="INDI")
first.append_child(types.GedcomStructure(tag="SEX", text="M"))
assert types.GedcomStructure(tag="INDI").children == []