diff --git a/gedcom7/__init__.py b/gedcom7/__init__.py index 64aa712..5b4a7e9 100644 --- a/gedcom7/__init__.py +++ b/gedcom7/__init__.py @@ -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 @@ -16,6 +16,7 @@ "format_value", "load", "loads", + "set_value", ] try: diff --git a/gedcom7/format.py b/gedcom7/format.py index 950254d..fc1a48b 100644 --- a/gedcom7/format.py +++ b/gedcom7/format.py @@ -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|``: 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): diff --git a/gedcom7/types.py b/gedcom7/types.py index d7a04d5..ff55283 100644 --- a/gedcom7/types.py +++ b/gedcom7/types.py @@ -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. diff --git a/test/test_format.py b/test/test_format.py index f9f7258..14517e9 100644 --- a/test/test_format.py +++ b/test/test_format.py @@ -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| 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] diff --git a/test/test_types.py b/test/test_types.py index 5e1a5f4..6c700a4 100644 --- a/test/test_types.py +++ b/test/test_types.py @@ -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 == []