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
76 changes: 10 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,11 @@
[![Python versions](https://img.shields.io/pypi/pyversions/gedcom7)](https://pypi.org/project/gedcom7/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

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

## Background

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/).

It does not attempt to parse files that are not standards compliant: a data
stream that violates the specification raises `GedcomParseError` rather than
being partially or silently misparsed.
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.

## Installation

Expand All @@ -30,73 +22,25 @@ python -m pip install gedcom7
```python
import gedcom7

with open("my_gedcom.ged", "r", encoding="utf-8") as f:
string = f.read()

records = gedcom7.loads(string)
```

Each record is a `GedcomStructure` with a `tag`, an optional `xref` and
`pointer`, the raw `text` payload, and `children`. The `value` property casts the
payload to the data type the specification gives that structure type:
with open("my_gedcom.ged", "rb") as f:
records = gedcom7.load(f)

```python
indi = records[1] # 0 @I1@ INDI
birt = indi.children[0] # 1 BIRT
date = birt.children[0] # 2 DATE 1 JAN 2000
date.type_id # 'https://gedcom.io/terms/v7/DATE'
date.value # Date(calendar=None, day=1, month='JAN', ...)
with open("out.ged", "wb") as f:
gedcom7.dump(records, f)
```

`value` is `None` where a structure has no payload, and the payload is returned
uninterpreted where the structure type is defined by an extension rather than by
the specification.

### Errors

`loads` raises `GedcomParseError` (a subclass of `ValueError`) for any data
stream that does not conform to the specification, reporting where the problem
is:

```python
from gedcom7 import GedcomParseError

try:
records = gedcom7.loads(string)
except GedcomParseError as exc:
print(exc) # line 42: malformed line: '1 NOTE @invalid'
print(exc.line_number) # 42
print(exc.line) # "1 NOTE @invalid"
```
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.

This covers malformed lines, prohibited level sequences, banned characters,
misplaced `CONT` continuations, duplicate or misplaced cross-reference
identifiers, pointers that resolve to nothing, and a missing header or trailer.
`loads` and `dumps` are the string equivalents. Non-conforming input raises `GedcomParseError`, a `ValueError` carrying `line_number`.

## Development

Install the package together with the development dependencies:

```
python -m pip install --group dev --editable .
pytest && mypy && ruff check . && ruff format --check .
```

Then run the checks:

```
pytest # tests
mypy # static type check (strict)
ruff check . # lint
ruff format . # format
```

## Releasing

The version is derived from git tags by
[setuptools-scm](https://setuptools-scm.readthedocs.io/); there is no version
string in the source tree. To release, push a `vX.Y.Z` tag and publish a GitHub
release for it — the `PyPI deploy` workflow builds the distributions and
uploads them via PyPI trusted publishing.
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.

## Credits

Expand Down
15 changes: 12 additions & 3 deletions gedcom7/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

from importlib.metadata import PackageNotFoundError, version

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

__all__ = ["GedcomError", "GedcomParseError", "loads"]
__all__ = [
"GedcomError",
"GedcomParseError",
"GedcomSerializeError",
"dump",
"dumps",
"load",
"loads",
]

try:
__version__ = version("gedcom7")
Expand Down
4 changes: 4 additions & 0 deletions gedcom7/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class GedcomError(Exception):
"""Base class for all errors raised by this package."""


class GedcomSerializeError(GedcomError, ValueError):
"""Raised when structures cannot be encoded as a conforming data stream."""


class GedcomParseError(GedcomError, ValueError):
"""Raised when a data stream does not conform to the GEDCOM 7 grammar.

Expand Down
25 changes: 24 additions & 1 deletion gedcom7/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@
from __future__ import annotations

import re
from typing import TYPE_CHECKING

from . import const, grammar
from .exceptions import GedcomParseError
from .types import GedcomStructure

if TYPE_CHECKING:
from typing import BinaryIO

# EOL = %x0D [%x0A] / %x0A -- CR-LF, CR, or LF
_EOL = re.compile(r"\r\n|\r|\n")
_LINE = re.compile(grammar.line)
_BANNED = re.compile(grammar.banned)
_TAGDEF = re.compile(grammar.tagdef)

# U+FEFF, the byte-order mark, may open a data stream and carries no meaning
_BOM = ""
_BOM = "\ufeff"


def _unescape(linestr: str) -> str:
Expand All @@ -28,6 +32,25 @@ def _unescape(linestr: str) -> str:
return linestr[1:] if linestr.startswith("@@") else linestr


def load(fp: BinaryIO) -> list[GedcomStructure]:
"""Load a GEDCOM 7 dataset from a binary file object.

The file must be opened in binary mode, e.g. ``open(path, "rb")``. GEDCOM 7
data streams are always UTF-8, and reading the bytes directly avoids the
encoding guesswork and newline translation that text mode would apply.
"""
data = fp.read()
try:
string = data.decode("utf-8")
except AttributeError:
raise TypeError(
'File must be opened in binary mode, e.g. use `open("my.ged", "rb")`'
) from None
except UnicodeDecodeError as exc:
raise GedcomParseError(f"data stream is not valid UTF-8: {exc}") from exc
return loads(string)


def loads(string: str) -> list[GedcomStructure]:
"""Load a GEDCOM 7 dataset from a string.

Expand Down
173 changes: 173 additions & 0 deletions gedcom7/serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""GEDCOM 7 serializer."""

from __future__ import annotations

import re
from typing import TYPE_CHECKING

from . import const, grammar
from .exceptions import GedcomSerializeError

if TYPE_CHECKING:
from collections.abc import Iterable
from typing import BinaryIO

from .types import GedcomStructure

_EOL = re.compile(r"\r\n|\r|\n")
_TAG = re.compile(grammar.tag)
_XREF = re.compile(grammar.xref)
_POINTER = re.compile(grammar.pointer)
_BANNED = re.compile(grammar.banned)
_TAGDEF = re.compile(grammar.tagdef)

_BOM = "\ufeff"


def _escape(linestr: str) -> str:
"""Escape a line string's leading "@" by doubling it.

The inverse of the parser's unescaping: only a leading ``@`` is doubled, so
``@@@@`` is written as ``@@@@@``.
"""
return "@" + linestr if linestr.startswith("@") else linestr


def _schema(records: Iterable[GedcomStructure]) -> dict[str, str]:
"""Map each URI declared by the header schema back to its extension tag."""
uris: dict[str, str] = {}
for record in records:
if record.tag != const.HEAD:
continue
for schema in record.children:
if schema.tag != const.SCHMA:
continue
for definition in schema.children:
if definition.tag != const.TAG:
continue
match = _TAGDEF.fullmatch(definition.text)
# A schema should map only one tag to each URI; keep the first.
if match is not None:
uris.setdefault(match.group("uri"), match.group("exttag"))
return uris


def _lines(
structure: GedcomStructure, level: int, uris: dict[str, str]
) -> Iterable[str]:
"""Yield the lines encoding a structure and everything below it."""
tag = uris.get(structure.tag, structure.tag)
if _TAG.fullmatch(tag) is None:
hint = (
" Add a matching HEAD.SCHMA.TAG definition to abbreviate it."
if "://" in tag
else ""
)
raise GedcomSerializeError(f"{tag!r} is not a valid tag.{hint}")

parts = [str(level)]
if structure.xref is not None and structure.xref != "":
if level != 0:
raise GedcomSerializeError(
f"only records may have a cross-reference identifier, but {tag} "
f"at level {level} has {structure.xref}"
)
if _XREF.fullmatch(structure.xref) is None or structure.xref == const.VOIDPTR:
raise GedcomSerializeError(
f"{structure.xref!r} is not a valid cross-reference identifier"
)
parts.append(structure.xref)
parts.append(tag)

if structure.pointer is not None and structure.pointer != "":
if structure.text:
raise GedcomSerializeError(
f"{tag} has both a pointer and a text payload; a line value is "
"one or the other"
)
if _POINTER.fullmatch(structure.pointer) is None:
raise GedcomSerializeError(f"{structure.pointer!r} is not a valid pointer")
parts.append(structure.pointer)
yield " ".join(parts)
else:
# A payload containing line terminators is split across the structure's
# own line and one CONT pseudo-structure per subsequent line.
payload = _EOL.split(structure.text)
if payload[0]:
parts.append(_escape(payload[0]))
yield " ".join(parts)
for continuation in payload[1:]:
yield (
f"{level + 1} {const.CONT} {_escape(continuation)}"
if continuation
else f"{level + 1} {const.CONT}"
)

for child in structure.children:
yield from _lines(child, level + 1, uris)


def dumps(
records: Iterable[GedcomStructure],
*,
line_terminator: str = "\n",
byte_order_mark: bool = True,
) -> str:
"""Serialize structures to a GEDCOM 7 data stream.

Levels are derived from the structure tree, payloads containing line
terminators are split into CONT pseudo-structures, and a leading "@" in a
payload is escaped. Extension tags stored as URIs are abbreviated using the
tag definitions in the header schema.

The specification says a data stream should begin with U+FEFF, so a byte
order mark is included by default; pass ``byte_order_mark=False`` to omit it.
Use :func:`dump` to write to a file, so that the encoding and the line
terminators are not altered on the way out.

Raises :class:`~gedcom7.exceptions.GedcomSerializeError` if the structures
cannot be encoded as conforming lines.
"""
if line_terminator not in ("\n", "\r\n", "\r"):
raise GedcomSerializeError(
f"{line_terminator!r} is not a valid line terminator; "
"use '\\n', '\\r\\n' or '\\r'"
)

records = list(records)
uris = _schema(records)
lines = [line for record in records for line in _lines(record, 0, uris)]

string = "".join(line + line_terminator for line in lines)
banned = _BANNED.search(string)
if banned:
raise GedcomSerializeError(
f"banned character U+{ord(banned.group()):04X} in payload"
)
return (_BOM if byte_order_mark else "") + string


def dump(
records: Iterable[GedcomStructure],
fp: BinaryIO,
*,
line_terminator: str = "\n",
byte_order_mark: bool = True,
) -> None:
"""Serialize structures to a binary file object.

The file must be opened in binary mode, e.g. ``open(path, "wb")``. GEDCOM 7
data streams are always UTF-8, and writing the bytes directly keeps text
mode from re-encoding them or rewriting the line terminators.
"""
data = dumps(
records,
line_terminator=line_terminator,
byte_order_mark=byte_order_mark,
).encode("utf-8")
try:
fp.write(data)
except TypeError:
raise TypeError(
'File must be opened in binary mode, e.g. use `open("my.ged", "wb")`'
) from None
5 changes: 4 additions & 1 deletion gedcom7/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ class GedcomStructure:
text: str
xref: str | None
children: list[GedcomStructure] = field(default_factory=list)
parent: GedcomStructure | None = None
# Excluded from comparison and repr: it points back up the tree, so including
# it would make __eq__ recurse endlessly and __repr__ print every ancestor.
# A structure's superstructure is implied by its position in the tree.
parent: GedcomStructure | None = field(default=None, compare=False, repr=False)

@property
def type_id(self) -> str | None:
Expand Down
2 changes: 1 addition & 1 deletion test/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_line_terminators(eol: str) -> None:

def test_byte_order_mark_is_ignored() -> None:
"""The data stream should begin with U+FEFF, which carries no meaning."""
records = gedcom7.loads("" + HEAD + TRLR)
records = gedcom7.loads("\ufeff" + HEAD + TRLR)
assert records[0].tag == "HEAD"


Expand Down
Loading