Skip to content

Commit 1471cbd

Browse files
authored
Add schema generation (#14)
* Add schema generation * Address comment * Address comment
1 parent a40cc44 commit 1471cbd

5 files changed

Lines changed: 375 additions & 36 deletions

File tree

‎gedcom7/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .exceptions import GedcomError, GedcomParseError, GedcomSerializeError
66
from .format import format_value, set_value
77
from .parser import load, loads
8-
from .serializer import dump, dumps
8+
from .serializer import dump, dumps, generate_schema
99

1010
__all__ = [
1111
"GedcomError",
@@ -14,6 +14,7 @@
1414
"dump",
1515
"dumps",
1616
"format_value",
17+
"generate_schema",
1718
"load",
1819
"loads",
1920
"set_value",

‎gedcom7/format.py‎

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,20 @@
2929
def format_value(value: types.DataType | None, type_id: str) -> str | None:
3030
"""Format a value as the payload string for its structure type.
3131
32-
``None`` and the empty string mean different things, and a caller deciding
33-
what to write has to tell them apart. ``None`` means there is no structure to
34-
write: it comes back for a value of ``None``, and for a false ``Y|<NULL>``,
35-
which the specification expresses by leaving the structure out rather than by
36-
writing it empty. The empty string means the structure is written with no
37-
payload, as for an empty :class:`~gedcom7.types.DatePeriod`, which is a legal
38-
date period and not the absence of one.
39-
40-
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value cannot
41-
be written as a payload conforming to its structure type, including when the
42-
structure type points at a record: a pointer belongs in the structure's
43-
pointer, and writing it as text would escape its leading "@" and turn the
44-
link into a line of text. A structure with no standard type has no data type
45-
to format, so its text is written as it stands rather than passed here.
32+
A structure type is named by its URI, and an empty payload and no structure
33+
at all are different answers::
34+
35+
V7 = "https://gedcom.io/terms/v7/"
36+
format_value(types.Date(year=2000), V7 + "DATE") -> "2000"
37+
format_value(types.DatePeriod(), V7 + "NO-DATE") -> "" empty payload
38+
format_value(False, V7 + "ADOP") -> None no structure
39+
40+
``None`` also comes back for a value of ``None``. A false ``Y|<NULL>`` gives
41+
it because the specification expresses that by omitting the structure.
42+
43+
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value has no
44+
conforming payload, or if the structure type points at a record, whose
45+
pointer belongs in the structure's ``pointer`` rather than its ``text``.
4646
"""
4747
if value is None:
4848
return None
@@ -62,30 +62,29 @@ def format_value(value: types.DataType | None, type_id: str) -> str | None:
6262
return format_function(value)
6363

6464

65+
# The counterpart of GedcomStructure.value, but a function rather than a method:
66+
# it formats, and a method would put an import of this module back into types.
6567
def set_value(
6668
structure: types.GedcomStructure,
6769
value: types.DataType,
6870
type_id: str | None = None,
6971
) -> None:
70-
"""Set a structure's payload from a value of its structure type's data type.
71-
72-
The counterpart of :attr:`~gedcom7.types.GedcomStructure.value`, which reads
73-
a payload back. Reading is a property of a structure the parser has already
74-
built; writing is something a writer does to one, which is why this is a
75-
function here rather than a method there.
76-
77-
Which data type applies follows from the structure type, and a structure only
78-
has one once it sits under its superstructure, since that is what gives its
79-
tag a meaning. Building a tree from the leaves up therefore means attaching a
80-
structure before setting its value, or naming the structure type here. Where
81-
no standard type applies the payload is carried uninterpreted, exactly as the
82-
reader returns it, so a string is set as it stands and anything else is
83-
refused.
84-
85-
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value cannot
86-
be written as a payload, which includes a false ``Y|<NULL>``: the
87-
specification expresses that by leaving the structure out, and removing a
88-
structure from its superstructure is the caller's to do.
72+
"""Set a structure's payload from a typed value, in place.
73+
74+
Which data type applies comes from the structure type, which a structure only
75+
has once it sits under its superstructure, so attach it first::
76+
77+
birth.append_child(date)
78+
set_value(date, types.Date(day=1, month="JAN", year=2000))
79+
date.text # "1 JAN 2000"
80+
81+
Pass ``type_id`` to name the structure type instead, for one not yet
82+
attached. Where no standard type applies the payload is uninterpreted, so a
83+
string is set as it stands and anything else is refused.
84+
85+
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the value has no
86+
payload, including a false ``Y|<NULL>``: the specification expresses that by
87+
omitting the structure, and removing it is the caller's to do.
8988
"""
9089
resolved = type_id if type_id is not None else structure.type_id
9190
if resolved is None:

‎gedcom7/serializer.py‎

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@
77

88
from . import const, grammar
99
from .exceptions import GedcomSerializeError
10+
from .types import GedcomStructure
1011

1112
if TYPE_CHECKING:
1213
from collections.abc import Iterable
1314
from typing import BinaryIO
1415

15-
from .types import GedcomStructure
16-
1716
_EOL = re.compile(r"\r\n|\r|\n")
1817
_TAG = re.compile(grammar.tag)
1918
_XREF = re.compile(grammar.xref)
2019
_POINTER = re.compile(grammar.pointer)
2120
_BANNED = re.compile(grammar.banned)
2221
_TAGDEF = re.compile(grammar.tagdef)
22+
_EXTTAG = re.compile(grammar.exttag)
2323

2424
_BOM = "\ufeff"
2525

@@ -52,6 +52,97 @@ def _schema(records: Iterable[GedcomStructure]) -> dict[str, str]:
5252
return uris
5353

5454

55+
def _extension_tag(uri: str, taken: set[str]) -> str:
56+
"""Invent an extension tag for a URI, avoiding every tag already spoken for.
57+
58+
The last path segment or fragment of the URI usually reads as a name, so it
59+
is the basis for the tag; a URI that yields nothing usable falls back to a
60+
plain counter. A tag already in use is stepped past rather than reused, since
61+
a tag standing for two things resolves to neither on the way back in.
62+
"""
63+
segment = re.split(r"[/#]", uri)[-1]
64+
base = "_" + re.sub(r"[^A-Z0-9_]", "", segment.upper())
65+
if _EXTTAG.fullmatch(base) is None:
66+
base = "_EXT"
67+
candidate, suffix = base, 1
68+
while candidate in taken:
69+
suffix += 1
70+
candidate = f"{base}{suffix}"
71+
return candidate
72+
73+
74+
def generate_schema(records: Iterable[GedcomStructure]) -> None:
75+
"""Add to HEAD the schema declarations dumps needs to write extension tags.
76+
77+
Takes a whole dataset, HEAD included, and modifies its structures in place::
78+
79+
records = gedcom7.loads(text)
80+
gedcom7.generate_schema(records) # records[0] gains HEAD.SCHMA.TAG
81+
gedcom7.dumps(records)
82+
83+
A tag held as a URI cannot be written until the header abbreviates it. Only
84+
HEAD changes; the URIs stay on their own structures for :func:`dumps` to
85+
substitute as it writes. Declarations already there are kept, so a second
86+
call does nothing.
87+
88+
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if there is no HEAD,
89+
or if a tag is neither writable nor a URI that can be abbreviated.
90+
"""
91+
# Read once: the records are walked several times below, and a caller passing
92+
# a one-shot iterator would otherwise have them silently disappear part way.
93+
records = list(records)
94+
head = next((record for record in records if record.tag == const.HEAD), None)
95+
if head is None:
96+
raise GedcomSerializeError(
97+
"a schema is declared in the HEAD pseudo-structure, and these "
98+
"records do not have one"
99+
)
100+
101+
declared = _schema(records)
102+
# Every literal tag in the document is spoken for as well: were a generated
103+
# tag to collide with one, that structure would resolve to the extension's
104+
# URI when the stream was read back.
105+
taken = set(declared.values())
106+
undeclared: dict[str, None] = {}
107+
108+
def visit(structure: GedcomStructure) -> None:
109+
if _TAG.fullmatch(structure.tag) is None:
110+
if structure.tag not in declared:
111+
undeclared.setdefault(structure.tag, None)
112+
else:
113+
taken.add(structure.tag)
114+
for child in structure.children:
115+
visit(child)
116+
117+
for record in records:
118+
visit(record)
119+
120+
if not undeclared:
121+
return
122+
123+
schema = next((c for c in head.children if c.tag == const.SCHMA), None)
124+
if schema is None:
125+
schema = GedcomStructure(tag=const.SCHMA)
126+
schema.parent = head
127+
gedc = next(
128+
(i for i, c in enumerate(head.children) if c.tag == const.GEDC), None
129+
)
130+
head.children.insert(0 if gedc is None else gedc + 1, schema)
131+
132+
# One tag per URI and one URI per tag: dumps picks arbitrarily between two
133+
# tags for a URI, and the parser refuses to resolve a tag that maps to two.
134+
for uri in undeclared:
135+
tag = _extension_tag(uri, taken)
136+
taken.add(tag)
137+
definition = f"{tag} {uri}"
138+
if _TAGDEF.fullmatch(definition) is None:
139+
raise GedcomSerializeError(
140+
f"{uri!r} cannot be declared in a schema: it is neither a tag "
141+
"this serializer can write nor a URI it can abbreviate"
142+
)
143+
schema.append_child(GedcomStructure(tag=const.TAG, text=definition))
144+
145+
55146
def _lines(
56147
structure: GedcomStructure, level: int, uris: dict[str, str]
57148
) -> Iterable[str]:

‎test/test_format.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,10 @@ def test_set_value_round_trips_through_the_serializer() -> None:
738738
"1 BIRT\n2 DATE 1 JAN 2000\n0 TRLR\n"
739739
)
740740
assert gedcom7.loads(text) == [head, individual, trlr]
741+
742+
743+
def test_format_value_docstring_examples() -> None:
744+
"""The examples in format_value's docstring have to be true."""
745+
assert gedcom7.format_value(types.Date(year=2000), V7 + "DATE") == "2000"
746+
assert gedcom7.format_value(types.DatePeriod(), V7 + "NO-DATE") == ""
747+
assert gedcom7.format_value(False, V7 + "ADOP") is None

0 commit comments

Comments
 (0)