Skip to content

Commit cfce5ab

Browse files
committed
Add write support
1 parent ad3941a commit cfce5ab

6 files changed

Lines changed: 402 additions & 69 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,11 @@
55
[![Python versions](https://img.shields.io/pypi/pyversions/gedcom7)](https://pypi.org/project/gedcom7/)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
77

8-
A [GEDCOM 7](https://gedcom.io/) parser for Python.
8+
A [GEDCOM 7](https://gedcom.io/) parser and serializer for Python.
99

1010
## Background
1111

12-
The parser is based on regular expressions generated directly from the ABNF
13-
grammar via [`abnf-to-regexp`](https://github.com/aas-core-works/abnf-to-regexp),
14-
and on the structure and payload tables extracted from the specification. It
15-
targets FamilySearch GEDCOM
16-
[7.0.18](https://github.com/FamilySearch/GEDCOM/blob/main/specification/).
17-
18-
It does not attempt to parse files that are not standards compliant: a data
19-
stream that violates the specification raises `GedcomParseError` rather than
20-
being partially or silently misparsed.
12+
The parser is based on regular expressions generated directly from the ABNF grammar via [`abnf-to-regexp`](https://github.com/aas-core-works/abnf-to-regexp), and on the structure and payload tables extracted from the specification. It targets FamilySearch GEDCOM [7.0.18](https://github.com/FamilySearch/GEDCOM/blob/main/specification/) and does not attempt to parse files that are not standards compliant: a data stream that violates the specification raises `GedcomParseError` rather than being silently misparsed.
2113

2214
## Installation
2315

@@ -30,73 +22,25 @@ python -m pip install gedcom7
3022
```python
3123
import gedcom7
3224

33-
with open("my_gedcom.ged", "r", encoding="utf-8") as f:
34-
string = f.read()
35-
36-
records = gedcom7.loads(string)
37-
```
38-
39-
Each record is a `GedcomStructure` with a `tag`, an optional `xref` and
40-
`pointer`, the raw `text` payload, and `children`. The `value` property casts the
41-
payload to the data type the specification gives that structure type:
25+
with open("my_gedcom.ged", encoding="utf-8") as f:
26+
records = gedcom7.loads(f.read())
4227

43-
```python
44-
indi = records[1] # 0 @I1@ INDI
45-
birt = indi.children[0] # 1 BIRT
46-
date = birt.children[0] # 2 DATE 1 JAN 2000
47-
date.type_id # 'https://gedcom.io/terms/v7/DATE'
48-
date.value # Date(calendar=None, day=1, month='JAN', ...)
28+
with open("out.ged", "w", encoding="utf-8") as f:
29+
gedcom7.dump(records, f)
4930
```
5031

51-
`value` is `None` where a structure has no payload, and the payload is returned
52-
uninterpreted where the structure type is defined by an extension rather than by
53-
the specification.
54-
55-
### Errors
56-
57-
`loads` raises `GedcomParseError` (a subclass of `ValueError`) for any data
58-
stream that does not conform to the specification, reporting where the problem
59-
is:
60-
61-
```python
62-
from gedcom7 import GedcomParseError
63-
64-
try:
65-
records = gedcom7.loads(string)
66-
except GedcomParseError as exc:
67-
print(exc) # line 42: malformed line: '1 NOTE @invalid'
68-
print(exc.line_number) # 42
69-
print(exc.line) # "1 NOTE @invalid"
70-
```
32+
Each record is a `GedcomStructure` with a `tag`, an optional `xref` and `pointer`, the raw `text` payload, and `children`. Its `value` property casts the payload to the data type the specification gives that structure type, and `type_id` is the structure type URI, or `None` where the type is defined by an extension.
7133

72-
This covers malformed lines, prohibited level sequences, banned characters,
73-
misplaced `CONT` continuations, duplicate or misplaced cross-reference
74-
identifiers, pointers that resolve to nothing, and a missing header or trailer.
34+
Serializing is the exact inverse of parsing, so `dumps(loads(text)) == text` holds byte for byte for a conforming file. `GedcomParseError` and `GedcomSerializeError` both subclass `ValueError`; parse errors carry `line_number` and `line`.
7535

7636
## Development
7737

78-
Install the package together with the development dependencies:
79-
8038
```
8139
python -m pip install --group dev --editable .
40+
pytest && mypy && ruff check .
8241
```
8342

84-
Then run the checks:
85-
86-
```
87-
pytest # tests
88-
mypy # static type check (strict)
89-
ruff check . # lint
90-
ruff format . # format
91-
```
92-
93-
## Releasing
94-
95-
The version is derived from git tags by
96-
[setuptools-scm](https://setuptools-scm.readthedocs.io/); there is no version
97-
string in the source tree. To release, push a `vX.Y.Z` tag and publish a GitHub
98-
release for it — the `PyPI deploy` workflow builds the distributions and
99-
uploads them via PyPI trusted publishing.
43+
The version is derived from git tags by [setuptools-scm](https://setuptools-scm.readthedocs.io/). To release, push a `vX.Y.Z` tag and publish a GitHub release for it.
10044

10145
## Credits
10246

‎gedcom7/__init__.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22

33
from importlib.metadata import PackageNotFoundError, version
44

5-
from .exceptions import GedcomError, GedcomParseError
5+
from .exceptions import GedcomError, GedcomParseError, GedcomSerializeError
66
from .parser import loads
7+
from .serializer import dump, dumps
78

8-
__all__ = ["GedcomError", "GedcomParseError", "loads"]
9+
__all__ = [
10+
"GedcomError",
11+
"GedcomParseError",
12+
"GedcomSerializeError",
13+
"dump",
14+
"dumps",
15+
"loads",
16+
]
917

1018
try:
1119
__version__ = version("gedcom7")

‎gedcom7/exceptions.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class GedcomError(Exception):
77
"""Base class for all errors raised by this package."""
88

99

10+
class GedcomSerializeError(GedcomError, ValueError):
11+
"""Raised when structures cannot be encoded as a conforming data stream."""
12+
13+
1014
class GedcomParseError(GedcomError, ValueError):
1115
"""Raised when a data stream does not conform to the GEDCOM 7 grammar.
1216

‎gedcom7/serializer.py‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""GEDCOM 7 serializer."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from typing import TYPE_CHECKING
7+
8+
from . import const, grammar
9+
from .exceptions import GedcomSerializeError
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Iterable
13+
from typing import IO
14+
15+
from .types import GedcomStructure
16+
17+
_EOL = re.compile(r"\r\n|\r|\n")
18+
_TAG = re.compile(grammar.tag)
19+
_XREF = re.compile(grammar.xref)
20+
_POINTER = re.compile(grammar.pointer)
21+
_BANNED = re.compile(grammar.banned)
22+
_TAGDEF = re.compile(grammar.tagdef)
23+
24+
_BOM = ""
25+
26+
27+
def _escape(linestr: str) -> str:
28+
"""Escape a line string's leading "@" by doubling it.
29+
30+
The inverse of the parser's unescaping: only a leading ``@`` is doubled, so
31+
``@@@@`` is written as ``@@@@@``.
32+
"""
33+
return "@" + linestr if linestr.startswith("@") else linestr
34+
35+
36+
def _schema(records: Iterable[GedcomStructure]) -> dict[str, str]:
37+
"""Map each URI declared by the header schema back to its extension tag."""
38+
uris: dict[str, str] = {}
39+
for record in records:
40+
if record.tag != const.HEAD:
41+
continue
42+
for schema in record.children:
43+
if schema.tag != const.SCHMA:
44+
continue
45+
for definition in schema.children:
46+
if definition.tag != const.TAG:
47+
continue
48+
match = _TAGDEF.fullmatch(definition.text)
49+
# A schema should map only one tag to each URI; keep the first.
50+
if match is not None:
51+
uris.setdefault(match.group("uri"), match.group("exttag"))
52+
return uris
53+
54+
55+
def _lines(
56+
structure: GedcomStructure, level: int, uris: dict[str, str]
57+
) -> Iterable[str]:
58+
"""Yield the lines encoding a structure and everything below it."""
59+
tag = uris.get(structure.tag, structure.tag)
60+
if _TAG.fullmatch(tag) is None:
61+
hint = (
62+
" Add a matching HEAD.SCHMA.TAG definition to abbreviate it."
63+
if "://" in tag
64+
else ""
65+
)
66+
raise GedcomSerializeError(f"{tag!r} is not a valid tag.{hint}")
67+
68+
parts = [str(level)]
69+
if structure.xref is not None and structure.xref != "":
70+
if level != 0:
71+
raise GedcomSerializeError(
72+
f"only records may have a cross-reference identifier, but {tag} "
73+
f"at level {level} has {structure.xref}"
74+
)
75+
if _XREF.fullmatch(structure.xref) is None or structure.xref == const.VOIDPTR:
76+
raise GedcomSerializeError(
77+
f"{structure.xref!r} is not a valid cross-reference identifier"
78+
)
79+
parts.append(structure.xref)
80+
parts.append(tag)
81+
82+
if structure.pointer is not None and structure.pointer != "":
83+
if structure.text:
84+
raise GedcomSerializeError(
85+
f"{tag} has both a pointer and a text payload; a line value is "
86+
"one or the other"
87+
)
88+
if _POINTER.fullmatch(structure.pointer) is None:
89+
raise GedcomSerializeError(f"{structure.pointer!r} is not a valid pointer")
90+
parts.append(structure.pointer)
91+
yield " ".join(parts)
92+
else:
93+
# A payload containing line terminators is split across the structure's
94+
# own line and one CONT pseudo-structure per subsequent line.
95+
payload = _EOL.split(structure.text)
96+
if payload[0]:
97+
parts.append(_escape(payload[0]))
98+
yield " ".join(parts)
99+
for continuation in payload[1:]:
100+
yield (
101+
f"{level + 1} {const.CONT} {_escape(continuation)}"
102+
if continuation
103+
else f"{level + 1} {const.CONT}"
104+
)
105+
106+
for child in structure.children:
107+
yield from _lines(child, level + 1, uris)
108+
109+
110+
def dumps(
111+
records: Iterable[GedcomStructure],
112+
*,
113+
line_terminator: str = "\n",
114+
byte_order_mark: bool = True,
115+
) -> str:
116+
"""Serialize structures to a GEDCOM 7 data stream.
117+
118+
Levels are derived from the structure tree, payloads containing line
119+
terminators are split into CONT pseudo-structures, and a leading "@" in a
120+
payload is escaped. Extension tags stored as URIs are abbreviated using the
121+
tag definitions in the header schema.
122+
123+
The specification says a data stream should begin with U+FEFF, so a byte
124+
order mark is included by default; pass ``byte_order_mark=False`` to omit it.
125+
Write the result with ``encoding="utf-8"``, not ``"utf-8-sig"``.
126+
127+
Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the structures
128+
cannot be encoded as conforming lines.
129+
"""
130+
if line_terminator not in ("\n", "\r\n", "\r"):
131+
raise GedcomSerializeError(
132+
f"{line_terminator!r} is not a valid line terminator; "
133+
"use '\\n', '\\r\\n' or '\\r'"
134+
)
135+
136+
records = list(records)
137+
uris = _schema(records)
138+
lines = [line for record in records for line in _lines(record, 0, uris)]
139+
140+
string = "".join(line + line_terminator for line in lines)
141+
banned = _BANNED.search(string)
142+
if banned:
143+
raise GedcomSerializeError(
144+
f"banned character U+{ord(banned.group()):04X} in payload"
145+
)
146+
return (_BOM if byte_order_mark else "") + string
147+
148+
149+
def dump(
150+
records: Iterable[GedcomStructure],
151+
fp: IO[str],
152+
*,
153+
line_terminator: str = "\n",
154+
byte_order_mark: bool = True,
155+
) -> None:
156+
"""Serialize structures to a text file opened with ``encoding="utf-8"``."""
157+
fp.write(
158+
dumps(
159+
records,
160+
line_terminator=line_terminator,
161+
byte_order_mark=byte_order_mark,
162+
)
163+
)

‎gedcom7/types.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ class GedcomStructure:
1818
text: str
1919
xref: str | None
2020
children: list[GedcomStructure] = field(default_factory=list)
21-
parent: GedcomStructure | None = None
21+
# Excluded from comparison and repr: it points back up the tree, so including
22+
# it would make __eq__ recurse endlessly and __repr__ print every ancestor.
23+
# A structure's superstructure is implied by its position in the tree.
24+
parent: GedcomStructure | None = field(default=None, compare=False, repr=False)
2225

2326
@property
2427
def type_id(self) -> str | None:

0 commit comments

Comments
 (0)