Skip to content

Layer 3: Repository

Domain-specific combinations of multiple stores. Each repository owns one domain concept.

ArchiveRepository

Content-addressed file archive with extracted text and raw data storage.

from ftm_lakehouse import get_archive

archive = get_archive("my_dataset")
checksum = archive.store(uri)
archive.exists(checksum)
archive.stream(checksum)

ftm_lakehouse.repository.ArchiveRepository

Bases: DatasetHandle

Repository for file archive operations.

Content-addressed blob storage (raw bytes): a blob is stored once per checksum.

Optionally, extracted text (by different origins) can be stored and retrieved. As well, other programs can write arbitrary additional data to the archive (such as pdf page thumbnails or their own file metadata) via put_data.

Example
archive = ArchiveRepository(dataset="my_data", uri="s3://bucket/dataset")

# Archive a file
checksum = archive.store("path/to/file.pdf")

# Stream file contents by checksum
for chunk in archive.stream(checksum):
    process(chunk)
Source code in ftm_lakehouse/repository/archive.py
class ArchiveRepository(DatasetHandle):
    """
    Repository for file archive operations.

    Content-addressed blob storage (raw bytes): a blob is stored once per
    checksum.

    Optionally, extracted text (by different origins) can be stored and
    retrieved. As well, other programs can write arbitrary additional data to
    the archive (such as pdf page thumbnails or their own file metadata) via
    [`put_data`][ArchiveRepository.put_data].

    Example:
        ```python
        archive = ArchiveRepository(dataset="my_data", uri="s3://bucket/dataset")

        # Archive a file
        checksum = archive.store("path/to/file.pdf")

        # Stream file contents by checksum
        for chunk in archive.stream(checksum):
            process(chunk)
        ```
    """

    def __init__(self, dataset: str, uri: Uri) -> None:
        super().__init__(dataset, uri)
        self._txts = get_store(
            self._store_uri, serialization_mode="auto", raise_on_nonexist=False
        )

    def exists(self, checksum: str) -> bool:
        """Check if blob exists for the given checksum."""
        return self._store.exists(path.ArchiveKey(checksum).blob)

    def iterate_checksums(self) -> Generator[str, None, None]:
        """Iterate the checksums of all blobs in the archive."""
        # exactly the checksum depth – a `put_data` path may end in "blob" too
        glob = f"*/*/*/*/{path.ARCHIVE_BLOB}"
        for key in self._store.iterate_keys(prefix=path.ARCHIVE, glob=glob):
            yield key.split("/")[-2]

    def stream(self, checksum: str) -> BytesGenerator:
        """Stream blob contents as bytes."""
        yield from self._store.stream(path.ArchiveKey(checksum).blob)

    def open(self, checksum: str) -> ContextManager[IO[bytes]]:
        """Get a file-like handle for reading."""
        return self._store.open(path.ArchiveKey(checksum).blob, mode=DEFAULT_MODE)

    def to_uri(self, checksum: str) -> str:
        return self._store.to_uri(path.ArchiveKey(checksum).blob)

    def local_path(self, checksum: str) -> ContextManager[Path]:
        """
        Get the local path to the blob.

        If storage is local, returns actual path. Otherwise, creates
        a temporary local copy that is cleaned up after context exit.
        """
        return self._store.local_path(path.ArchiveKey(checksum).blob)

    def store(
        self, uri: Uri, checksum: str | None = None, tag_updated: bool = True
    ) -> str:
        """
        Archive a file from a local or remote URI.

        Args:
            uri: Local or remote URI to the file
            checksum: Content hash (skip computation if provided)
            tag_updated: Touch the `archive/last_updated` tag

        Returns:
            checksum
        """
        checksum = self.store_blob(uri, checksum)
        if tag_updated:
            # Notify archive was updated
            self.touch()
        self.log.info(f"Archived `{mask_uri(uri)}` ({checksum})", checksum=checksum)
        return checksum

    def store_blob(self, uri: Uri, checksum: str | None = None) -> str:
        """
        Store bytes blob from given uri if it doesn't exist yet.

        Args:
            uri: Local or remote URI to the file
            checksum: Content hash (skip computation if provided)

        Returns:
            checksum
        """
        if checksum:
            validate_checksum(checksum)
        if checksum and self.exists(checksum):
            self.log.debug("Blob already exists, skipping", checksum=checksum)
            return checksum

        with open_virtual(uri, algorithm=CHECKSUM_ALGORITHM) as fh:
            if self.exists(fh.checksum):
                self.log.debug("Blob already exists, skipping", checksum=fh.checksum)
                return fh.checksum

            self.log.info(f"Storing blob `{fh.checksum}` ...", checksum=fh.checksum)
            self.write_blob(fh, fh.checksum, check_exists=False)
            return fh.checksum

    def write_blob(
        self, fh: BinaryIO, checksum: str | None = None, check_exists: bool = True
    ) -> str:
        """Write a blob from the given open file-handler"""
        if check_exists and checksum and self.exists(checksum):
            self.log.debug("Blob already exists, skipping", checksum=checksum)
            return checksum
        if not checksum:
            checksum = make_checksum(fh, algorithm=CHECKSUM_ALGORITHM)
            if self.exists(checksum):
                self.log.debug("Blob already exists, skipping", checksum=checksum)
                return checksum
            fh.seek(0)
        with self._store.open(path.ArchiveKey(checksum).blob, "wb") as out:
            stream(fh, out)
        return checksum

    def put_txt(self, checksum: str, text: str, origin: str = DEFAULT_ORIGIN) -> None:
        """Store extracted text for a file.

        Raises:
            ValueError: If ``checksum`` is not a valid SHA256 hex digest or
                ``origin`` is not a safe path component
                (see `validate_origin`).
        """
        key = path.ArchiveKey(checksum).txt(origin)
        self._txts.put(key, text)

    def get_txt(self, checksum: str, origin: str | None = None) -> str | None:
        """Get extracted text for a file. If `origin`, get by this specific
        extraction, otherwise get the first txt value (no guaranteed order)"""
        if origin:
            key = path.ArchiveKey(checksum).txt(origin)
            return self._txts.get(key)
        for value in self._txts.iterate_values(
            prefix=path.ArchiveKey(checksum), glob="*.txt"
        ):
            return value

    def put_data(self, checksum: str, path: str, data: bytes) -> None:
        """Store raw data at the given path in the checksum's archive directory

        Raises:
            ValueError: If ``checksum`` is not a valid SHA256 hex digest or
                ``path`` is empty, absolute, has a ``..`` segment or is
                reserved (``blob`` or a top-level ``*.txt``).
        """
        self._store.put(self._data_key(checksum, path), data)

    def get_data(self, checksum: str, path: str) -> bytes:
        """Get raw data at the given path in the checksum's archive directory

        Raises:
            ValueError: If ``checksum`` is not a valid SHA256 hex digest or
                ``path`` is empty, absolute, has a ``..`` segment or is
                reserved (``blob`` or a top-level ``*.txt``).
        """
        return self._store.get(self._data_key(checksum, path))

    def _data_key(self, checksum: str, key: str) -> str:
        # out here because `put_data` / `get_data` shadow the `path` module
        relative = validate_relative_uri(key)
        if not relative:  # "." normalises to nothing
            raise ValueError(f"Invalid empty data path: `{key}`")
        # the file's content and what `get_txt` reads as extracted text
        if relative == path.ARCHIVE_BLOB or (
            "/" not in relative and relative.endswith(".txt")
        ):
            raise ValueError(f"Reserved data path: `{key}`")
        return join_relpaths(path.ArchiveKey(checksum), relative)

    def touch(self) -> datetime:
        with self._tags.touch(tag.ARCHIVE_UPDATED) as now:
            return now

exists(checksum)

Check if blob exists for the given checksum.

Source code in ftm_lakehouse/repository/archive.py
def exists(self, checksum: str) -> bool:
    """Check if blob exists for the given checksum."""
    return self._store.exists(path.ArchiveKey(checksum).blob)

get_data(checksum, path)

Get raw data at the given path in the checksum's archive directory

Raises:

Type Description
ValueError

If checksum is not a valid SHA256 hex digest or path is empty, absolute, has a .. segment or is reserved (blob or a top-level *.txt).

Source code in ftm_lakehouse/repository/archive.py
def get_data(self, checksum: str, path: str) -> bytes:
    """Get raw data at the given path in the checksum's archive directory

    Raises:
        ValueError: If ``checksum`` is not a valid SHA256 hex digest or
            ``path`` is empty, absolute, has a ``..`` segment or is
            reserved (``blob`` or a top-level ``*.txt``).
    """
    return self._store.get(self._data_key(checksum, path))

get_txt(checksum, origin=None)

Get extracted text for a file. If origin, get by this specific extraction, otherwise get the first txt value (no guaranteed order)

Source code in ftm_lakehouse/repository/archive.py
def get_txt(self, checksum: str, origin: str | None = None) -> str | None:
    """Get extracted text for a file. If `origin`, get by this specific
    extraction, otherwise get the first txt value (no guaranteed order)"""
    if origin:
        key = path.ArchiveKey(checksum).txt(origin)
        return self._txts.get(key)
    for value in self._txts.iterate_values(
        prefix=path.ArchiveKey(checksum), glob="*.txt"
    ):
        return value

iterate_checksums()

Iterate the checksums of all blobs in the archive.

Source code in ftm_lakehouse/repository/archive.py
def iterate_checksums(self) -> Generator[str, None, None]:
    """Iterate the checksums of all blobs in the archive."""
    # exactly the checksum depth – a `put_data` path may end in "blob" too
    glob = f"*/*/*/*/{path.ARCHIVE_BLOB}"
    for key in self._store.iterate_keys(prefix=path.ARCHIVE, glob=glob):
        yield key.split("/")[-2]

local_path(checksum)

Get the local path to the blob.

If storage is local, returns actual path. Otherwise, creates a temporary local copy that is cleaned up after context exit.

Source code in ftm_lakehouse/repository/archive.py
def local_path(self, checksum: str) -> ContextManager[Path]:
    """
    Get the local path to the blob.

    If storage is local, returns actual path. Otherwise, creates
    a temporary local copy that is cleaned up after context exit.
    """
    return self._store.local_path(path.ArchiveKey(checksum).blob)

open(checksum)

Get a file-like handle for reading.

Source code in ftm_lakehouse/repository/archive.py
def open(self, checksum: str) -> ContextManager[IO[bytes]]:
    """Get a file-like handle for reading."""
    return self._store.open(path.ArchiveKey(checksum).blob, mode=DEFAULT_MODE)

put_data(checksum, path, data)

Store raw data at the given path in the checksum's archive directory

Raises:

Type Description
ValueError

If checksum is not a valid SHA256 hex digest or path is empty, absolute, has a .. segment or is reserved (blob or a top-level *.txt).

Source code in ftm_lakehouse/repository/archive.py
def put_data(self, checksum: str, path: str, data: bytes) -> None:
    """Store raw data at the given path in the checksum's archive directory

    Raises:
        ValueError: If ``checksum`` is not a valid SHA256 hex digest or
            ``path`` is empty, absolute, has a ``..`` segment or is
            reserved (``blob`` or a top-level ``*.txt``).
    """
    self._store.put(self._data_key(checksum, path), data)

put_txt(checksum, text, origin=DEFAULT_ORIGIN)

Store extracted text for a file.

Raises:

Type Description
ValueError

If checksum is not a valid SHA256 hex digest or origin is not a safe path component (see validate_origin).

Source code in ftm_lakehouse/repository/archive.py
def put_txt(self, checksum: str, text: str, origin: str = DEFAULT_ORIGIN) -> None:
    """Store extracted text for a file.

    Raises:
        ValueError: If ``checksum`` is not a valid SHA256 hex digest or
            ``origin`` is not a safe path component
            (see `validate_origin`).
    """
    key = path.ArchiveKey(checksum).txt(origin)
    self._txts.put(key, text)

store(uri, checksum=None, tag_updated=True)

Archive a file from a local or remote URI.

Parameters:

Name Type Description Default
uri Uri

Local or remote URI to the file

required
checksum str | None

Content hash (skip computation if provided)

None
tag_updated bool

Touch the archive/last_updated tag

True

Returns:

Type Description
str

checksum

Source code in ftm_lakehouse/repository/archive.py
def store(
    self, uri: Uri, checksum: str | None = None, tag_updated: bool = True
) -> str:
    """
    Archive a file from a local or remote URI.

    Args:
        uri: Local or remote URI to the file
        checksum: Content hash (skip computation if provided)
        tag_updated: Touch the `archive/last_updated` tag

    Returns:
        checksum
    """
    checksum = self.store_blob(uri, checksum)
    if tag_updated:
        # Notify archive was updated
        self.touch()
    self.log.info(f"Archived `{mask_uri(uri)}` ({checksum})", checksum=checksum)
    return checksum

store_blob(uri, checksum=None)

Store bytes blob from given uri if it doesn't exist yet.

Parameters:

Name Type Description Default
uri Uri

Local or remote URI to the file

required
checksum str | None

Content hash (skip computation if provided)

None

Returns:

Type Description
str

checksum

Source code in ftm_lakehouse/repository/archive.py
def store_blob(self, uri: Uri, checksum: str | None = None) -> str:
    """
    Store bytes blob from given uri if it doesn't exist yet.

    Args:
        uri: Local or remote URI to the file
        checksum: Content hash (skip computation if provided)

    Returns:
        checksum
    """
    if checksum:
        validate_checksum(checksum)
    if checksum and self.exists(checksum):
        self.log.debug("Blob already exists, skipping", checksum=checksum)
        return checksum

    with open_virtual(uri, algorithm=CHECKSUM_ALGORITHM) as fh:
        if self.exists(fh.checksum):
            self.log.debug("Blob already exists, skipping", checksum=fh.checksum)
            return fh.checksum

        self.log.info(f"Storing blob `{fh.checksum}` ...", checksum=fh.checksum)
        self.write_blob(fh, fh.checksum, check_exists=False)
        return fh.checksum

stream(checksum)

Stream blob contents as bytes.

Source code in ftm_lakehouse/repository/archive.py
def stream(self, checksum: str) -> BytesGenerator:
    """Stream blob contents as bytes."""
    yield from self._store.stream(path.ArchiveKey(checksum).blob)

write_blob(fh, checksum=None, check_exists=True)

Write a blob from the given open file-handler

Source code in ftm_lakehouse/repository/archive.py
def write_blob(
    self, fh: BinaryIO, checksum: str | None = None, check_exists: bool = True
) -> str:
    """Write a blob from the given open file-handler"""
    if check_exists and checksum and self.exists(checksum):
        self.log.debug("Blob already exists, skipping", checksum=checksum)
        return checksum
    if not checksum:
        checksum = make_checksum(fh, algorithm=CHECKSUM_ALGORITHM)
        if self.exists(checksum):
            self.log.debug("Blob already exists, skipping", checksum=checksum)
            return checksum
        fh.seek(0)
    with self._store.open(path.ArchiveKey(checksum).blob, "wb") as out:
        stream(fh, out)
    return checksum

EntityRepository

A dataset's entities and statements: writes go through the journal, reads and maintenance to the parquet store.

from ftmq.query import C, Query

from ftm_lakehouse import get_entities

entities = get_entities("my_dataset")
entities.add(entity, origin="import")
entities.writer(origin="import")
entities.flush()
entities.query(Query(C(origin="import")))

ftm_lakehouse.repository.EntityRepository

Bases: DatasetHandle

Entities and statements of a dataset: writes go to the journal, reads to the parquet store, flush moves rows between them.

Example
repo = get_entities("my_data")

with repo.writer(origin="import") as writer:
    writer.add_entity(entity)
repo.flush()

for entity in repo.query(Query(C(origin="import"))):
    process(entity)
Source code in ftm_lakehouse/repository/entities/main.py
class EntityRepository(DatasetHandle):
    """Entities and statements of a dataset: writes go to the journal, reads to
    the parquet store, [`flush`][EntityRepository.flush] moves rows between them.

    Example:
        ```python
        repo = get_entities("my_data")

        with repo.writer(origin="import") as writer:
            writer.add_entity(entity)
        repo.flush()

        for entity in repo.query(Query(C(origin="import"))):
            process(entity)
        ```
    """

    def __init__(
        self,
        dataset: str,
        uri: Uri,
    ) -> None:
        super().__init__(dataset, uri)
        if self._is_api and type(self) is EntityRepository:
            raise RuntimeError(
                "`EntityRepository` cannot run against an http uri directly "
                "– resolve the repository via `get_entities()`"
            )
        self.shards = self._model.shards
        self.compression = self._model.compression
        self._journal = get_journal(dataset)
        self.ENTITIES_JSON = EntitiesArtifact(self).key
        self.EXPORTS_STATEMENTS = StatementsArtifact(self).key

    @cached_property
    def statements(self) -> ParquetStore:
        """The dataset's parquet store, built lazily; raises in api mode."""
        if self._is_api:
            raise RuntimeError(
                f"`{type(self).__name__}.statements` is not available in API mode"
            )
        return ParquetStore(self.uri, self.dataset, self.shards)

    def writer(
        self, origin: str | None = None, role: str | None = None
    ) -> BaseJournalWriter:
        """A bulk journal writer – inserts its tail on success, drops it on error.

        Example:
            ```python
            with repo.writer(origin="import") as writer:
                writer.add_entity(entity)
            ```

        Args:
            origin: Origin of the statements written through it.
            role: Who asserts them, for statements that carry no role.
        """
        return self._journal.writer(origin, role)

    def add(
        self,
        entity: EntityProxy,
        origin: str | None = None,
        fragment: str | None = None,
        role: str | None = None,
    ) -> None:
        """Add a single entity to the journal."""
        self.add_many([entity], origin, fragment, role)

    def add_many(
        self,
        entities: Iterable[EntityProxy],
        origin: str | None = None,
        fragment: str | None = None,
        role: str | None = None,
    ) -> None:
        """Add an entity iterator to the journal."""
        with self.writer(origin, role) as writer:
            for entity in entities:
                writer.add_entity(entity, fragment=fragment)

    def flush(self) -> int:
        """Drain the journal into the parquet store.

        A concurrent flush makes this one a no-op, so ``0`` does not mean the
        journal is empty.

        Returns:
            Number of statements appended.
        """
        with Took() as t:
            self.log.info("Flushing journal ...", journal=mask_uri(self._journal.uri))
            total = self.write_batches(self._journal.flush_batches())
        if total:
            self.log.info(
                "Flushed statements from journal to lake",
                count=total,
                took=t.took,
                journal=mask_uri(self._journal.uri),
            )
        return total

    def write_batches(self, tables: Iterable[pa.Table]) -> int:
        """Append `JOURNAL_SCHEMA` tables to parquet – the journal drain's write
        loop. Each table is durable before the next is pulled.

        Args:
            tables: Stream of packed statement tables.

        Returns:
            Number of rows written.
        """
        store = self.statements  # raises in api mode, also for no tables
        total = 0
        for table in tables:
            if not table.num_rows:
                continue
            store.append(table)
            total += table.num_rows
        return total

    def write_stream(self, tables: Iterable[pa.Table]) -> int:
        """Append `JOURNAL_SCHEMA` tables to parquet as one file per partition –
        the bulk imports' write
        ([`write_stream`][ftm_lakehouse.storage.parquet.ParquetStore.write_stream]).

        Args:
            tables: Stream of packed statement tables.

        Returns:
            Number of rows written.
        """
        return self.statements.write_stream(tables)  # raises in api mode

    def merge(self, force: bool = False) -> None:
        """Flush, then [`merge`][ParquetStore.merge] the parquet store – ``force``
        rewrites clean partitions too."""
        store = self.statements  # raises in api mode, ahead of the flush
        self.flush()
        store.merge(force)

    def shard(self, shards: int) -> None:
        """Flush, then [`shard`][ParquetStore.shard] the parquet store – the
        storage half; [`ShardOperation`][ftm_lakehouse.operation.maintenance.ShardOperation]
        writes the new count to ``config.yml``.

        Args:
            shards: Target shard count; ``<= 1`` means a single shard.
        """
        store = self.statements  # raises in api mode, ahead of the flush
        self.flush()
        store.shard(shards)
        self.shards = shards

    def query_statements_data(self, q: Query | None = None) -> Iterator[StatementDict]:
        """[`query_statements`][EntityRepository.query_statements] as plain dicts –
        the stored columns, timestamps as ISO strings, as the api sends them."""
        for row in self.statements._statement_data(q):
            yield cast(
                StatementDict,
                {
                    k: v.isoformat() if isinstance(v, datetime) else v
                    for k, v in row.items()
                },
            )

    def query(
        self, q: Query | None = None, *, flush_first: bool = False
    ) -> StatementEntities:
        """Query entities from the parquet store.

        Args:
            q: Filters, plus ordering / slicing.
            flush_first: Flush the journal first.
        """
        if flush_first:
            self.flush()
        yield from self.statements.query(q)

    def query_statements(
        self, q: Query | None = None, *, flush_first: bool = False
    ) -> Statements:
        """Query `LakehouseStatement` objects from the parquet store.

        Args:
            q: Filters, plus ordering / slicing.
            flush_first: Flush the journal first.
        """
        if flush_first:
            self.flush()
        yield from self.statements.query_statements(q)

    def get(self, entity_id: str, flush_first: bool = False) -> StatementEntity | None:
        """Get a single entity by ID."""
        q = Query(M(entity_id=entity_id))
        for entity in self.query(q, flush_first=flush_first):
            return entity
        return None

    def stream(self) -> ValueEntities:
        """Stream the exported ``entities.ftm.json`` – not the parquet store."""
        if self._store.exists(self.ENTITIES_JSON):
            with self._store.open(
                self.ENTITIES_JSON, "rb", compression=self.compression
            ) as raw:
                yield from smart_read_proxies(raw)

    def delete_entity(self, entity_id: str, origin: str | None = None) -> int:
        """Tombstone every statement of an entity, parquet and journal – one per
        live row, carrying its ``fragment`` and ``role`` so it shadows that row.

        Args:
            entity_id: The entity to delete.
            origin: Only delete its statements from this origin.

        Returns:
            Number of tombstones written.
        """
        now = utc_now()
        stmts = self._collect_entity_statements(entity_id)
        if origin:
            stmts = [s for s in stmts if s.origin == origin]
        with self.writer() as w:
            for stmt in stmts:
                w.add_statement(stmt, deleted_at=now)
        return len(stmts)

    def delete_statement(
        self,
        stmt: Statement,
        fragment: str | None = None,
        role: str | None = None,
    ) -> None:
        """Tombstone one statement.

        Args:
            stmt: The statement – a `LakehouseStatement` read back from
                [`query_statements`][EntityRepository.query_statements] carries
                its own fragment and role.
            fragment: The row's fragment, for a plain ``Statement``.
            role: The row's role, for a plain ``Statement``.
        """
        with self.writer() as w:
            w.add_statement(stmt, deleted_at=utc_now(), fragment=fragment, role=role)

    def delete_origin(self, origin: str) -> None:
        """Physically drop an origin: flush, then
        [`ParquetStore.delete_origin`][ParquetStore.delete_origin]. Rows journalled
        into it while this runs survive – stop the writers to be sure.

        Args:
            origin: The origin to drop.

        Raises:
            ValueError: If ``origin`` is not a safe origin name.
            RuntimeError: When the write fence cannot be acquired.
        """
        # validate before flushing – a bad origin must cost nothing
        origin = validate_origin(origin)
        self.flush()
        self.statements.delete_origin(origin)

    def _collect_entity_statements(self, entity_id: str) -> list[LakehouseStatement]:
        """An entity's statements from parquet and journal, one per
        ``dedupe_key`` – the journal's win."""
        stmts_by_key: dict[str, LakehouseStatement] = {}

        q = Query(M(entity_id=entity_id))
        for stmt in self.query_statements(q):
            stmt = cast(LakehouseStatement, stmt)
            if stmt.id:
                stmts_by_key[stmt.dedupe_key] = stmt

        for stmt in self._journal.iterate_entity(entity_id):
            if stmt.id:
                stmts_by_key[stmt.dedupe_key] = stmt

        return list(stmts_by_key.values())

    @property
    def version(self) -> int | None:
        """Current version of the main Delta table."""
        return self.statements.version

statements cached property

The dataset's parquet store, built lazily; raises in api mode.

version property

Current version of the main Delta table.

add(entity, origin=None, fragment=None, role=None)

Add a single entity to the journal.

Source code in ftm_lakehouse/repository/entities/main.py
def add(
    self,
    entity: EntityProxy,
    origin: str | None = None,
    fragment: str | None = None,
    role: str | None = None,
) -> None:
    """Add a single entity to the journal."""
    self.add_many([entity], origin, fragment, role)

add_many(entities, origin=None, fragment=None, role=None)

Add an entity iterator to the journal.

Source code in ftm_lakehouse/repository/entities/main.py
def add_many(
    self,
    entities: Iterable[EntityProxy],
    origin: str | None = None,
    fragment: str | None = None,
    role: str | None = None,
) -> None:
    """Add an entity iterator to the journal."""
    with self.writer(origin, role) as writer:
        for entity in entities:
            writer.add_entity(entity, fragment=fragment)

delete_entity(entity_id, origin=None)

Tombstone every statement of an entity, parquet and journal – one per live row, carrying its fragment and role so it shadows that row.

Parameters:

Name Type Description Default
entity_id str

The entity to delete.

required
origin str | None

Only delete its statements from this origin.

None

Returns:

Type Description
int

Number of tombstones written.

Source code in ftm_lakehouse/repository/entities/main.py
def delete_entity(self, entity_id: str, origin: str | None = None) -> int:
    """Tombstone every statement of an entity, parquet and journal – one per
    live row, carrying its ``fragment`` and ``role`` so it shadows that row.

    Args:
        entity_id: The entity to delete.
        origin: Only delete its statements from this origin.

    Returns:
        Number of tombstones written.
    """
    now = utc_now()
    stmts = self._collect_entity_statements(entity_id)
    if origin:
        stmts = [s for s in stmts if s.origin == origin]
    with self.writer() as w:
        for stmt in stmts:
            w.add_statement(stmt, deleted_at=now)
    return len(stmts)

delete_origin(origin)

Physically drop an origin: flush, then ParquetStore.delete_origin. Rows journalled into it while this runs survive – stop the writers to be sure.

Parameters:

Name Type Description Default
origin str

The origin to drop.

required

Raises:

Type Description
ValueError

If origin is not a safe origin name.

RuntimeError

When the write fence cannot be acquired.

Source code in ftm_lakehouse/repository/entities/main.py
def delete_origin(self, origin: str) -> None:
    """Physically drop an origin: flush, then
    [`ParquetStore.delete_origin`][ParquetStore.delete_origin]. Rows journalled
    into it while this runs survive – stop the writers to be sure.

    Args:
        origin: The origin to drop.

    Raises:
        ValueError: If ``origin`` is not a safe origin name.
        RuntimeError: When the write fence cannot be acquired.
    """
    # validate before flushing – a bad origin must cost nothing
    origin = validate_origin(origin)
    self.flush()
    self.statements.delete_origin(origin)

delete_statement(stmt, fragment=None, role=None)

Tombstone one statement.

Parameters:

Name Type Description Default
stmt Statement

The statement – a LakehouseStatement read back from query_statements carries its own fragment and role.

required
fragment str | None

The row's fragment, for a plain Statement.

None
role str | None

The row's role, for a plain Statement.

None
Source code in ftm_lakehouse/repository/entities/main.py
def delete_statement(
    self,
    stmt: Statement,
    fragment: str | None = None,
    role: str | None = None,
) -> None:
    """Tombstone one statement.

    Args:
        stmt: The statement – a `LakehouseStatement` read back from
            [`query_statements`][EntityRepository.query_statements] carries
            its own fragment and role.
        fragment: The row's fragment, for a plain ``Statement``.
        role: The row's role, for a plain ``Statement``.
    """
    with self.writer() as w:
        w.add_statement(stmt, deleted_at=utc_now(), fragment=fragment, role=role)

flush()

Drain the journal into the parquet store.

A concurrent flush makes this one a no-op, so 0 does not mean the journal is empty.

Returns:

Type Description
int

Number of statements appended.

Source code in ftm_lakehouse/repository/entities/main.py
def flush(self) -> int:
    """Drain the journal into the parquet store.

    A concurrent flush makes this one a no-op, so ``0`` does not mean the
    journal is empty.

    Returns:
        Number of statements appended.
    """
    with Took() as t:
        self.log.info("Flushing journal ...", journal=mask_uri(self._journal.uri))
        total = self.write_batches(self._journal.flush_batches())
    if total:
        self.log.info(
            "Flushed statements from journal to lake",
            count=total,
            took=t.took,
            journal=mask_uri(self._journal.uri),
        )
    return total

get(entity_id, flush_first=False)

Get a single entity by ID.

Source code in ftm_lakehouse/repository/entities/main.py
def get(self, entity_id: str, flush_first: bool = False) -> StatementEntity | None:
    """Get a single entity by ID."""
    q = Query(M(entity_id=entity_id))
    for entity in self.query(q, flush_first=flush_first):
        return entity
    return None

merge(force=False)

Flush, then merge the parquet store – force rewrites clean partitions too.

Source code in ftm_lakehouse/repository/entities/main.py
def merge(self, force: bool = False) -> None:
    """Flush, then [`merge`][ParquetStore.merge] the parquet store – ``force``
    rewrites clean partitions too."""
    store = self.statements  # raises in api mode, ahead of the flush
    self.flush()
    store.merge(force)

query(q=None, *, flush_first=False)

Query entities from the parquet store.

Parameters:

Name Type Description Default
q Query | None

Filters, plus ordering / slicing.

None
flush_first bool

Flush the journal first.

False
Source code in ftm_lakehouse/repository/entities/main.py
def query(
    self, q: Query | None = None, *, flush_first: bool = False
) -> StatementEntities:
    """Query entities from the parquet store.

    Args:
        q: Filters, plus ordering / slicing.
        flush_first: Flush the journal first.
    """
    if flush_first:
        self.flush()
    yield from self.statements.query(q)

query_statements(q=None, *, flush_first=False)

Query LakehouseStatement objects from the parquet store.

Parameters:

Name Type Description Default
q Query | None

Filters, plus ordering / slicing.

None
flush_first bool

Flush the journal first.

False
Source code in ftm_lakehouse/repository/entities/main.py
def query_statements(
    self, q: Query | None = None, *, flush_first: bool = False
) -> Statements:
    """Query `LakehouseStatement` objects from the parquet store.

    Args:
        q: Filters, plus ordering / slicing.
        flush_first: Flush the journal first.
    """
    if flush_first:
        self.flush()
    yield from self.statements.query_statements(q)

query_statements_data(q=None)

query_statements as plain dicts – the stored columns, timestamps as ISO strings, as the api sends them.

Source code in ftm_lakehouse/repository/entities/main.py
def query_statements_data(self, q: Query | None = None) -> Iterator[StatementDict]:
    """[`query_statements`][EntityRepository.query_statements] as plain dicts –
    the stored columns, timestamps as ISO strings, as the api sends them."""
    for row in self.statements._statement_data(q):
        yield cast(
            StatementDict,
            {
                k: v.isoformat() if isinstance(v, datetime) else v
                for k, v in row.items()
            },
        )

shard(shards)

Flush, then shard the parquet store – the storage half; ShardOperation writes the new count to config.yml.

Parameters:

Name Type Description Default
shards int

Target shard count; <= 1 means a single shard.

required
Source code in ftm_lakehouse/repository/entities/main.py
def shard(self, shards: int) -> None:
    """Flush, then [`shard`][ParquetStore.shard] the parquet store – the
    storage half; [`ShardOperation`][ftm_lakehouse.operation.maintenance.ShardOperation]
    writes the new count to ``config.yml``.

    Args:
        shards: Target shard count; ``<= 1`` means a single shard.
    """
    store = self.statements  # raises in api mode, ahead of the flush
    self.flush()
    store.shard(shards)
    self.shards = shards

stream()

Stream the exported entities.ftm.json – not the parquet store.

Source code in ftm_lakehouse/repository/entities/main.py
def stream(self) -> ValueEntities:
    """Stream the exported ``entities.ftm.json`` – not the parquet store."""
    if self._store.exists(self.ENTITIES_JSON):
        with self._store.open(
            self.ENTITIES_JSON, "rb", compression=self.compression
        ) as raw:
            yield from smart_read_proxies(raw)

write_batches(tables)

Append JOURNAL_SCHEMA tables to parquet – the journal drain's write loop. Each table is durable before the next is pulled.

Parameters:

Name Type Description Default
tables Iterable[Table]

Stream of packed statement tables.

required

Returns:

Type Description
int

Number of rows written.

Source code in ftm_lakehouse/repository/entities/main.py
def write_batches(self, tables: Iterable[pa.Table]) -> int:
    """Append `JOURNAL_SCHEMA` tables to parquet – the journal drain's write
    loop. Each table is durable before the next is pulled.

    Args:
        tables: Stream of packed statement tables.

    Returns:
        Number of rows written.
    """
    store = self.statements  # raises in api mode, also for no tables
    total = 0
    for table in tables:
        if not table.num_rows:
            continue
        store.append(table)
        total += table.num_rows
    return total

write_stream(tables)

Append JOURNAL_SCHEMA tables to parquet as one file per partition – the bulk imports' write (write_stream).

Parameters:

Name Type Description Default
tables Iterable[Table]

Stream of packed statement tables.

required

Returns:

Type Description
int

Number of rows written.

Source code in ftm_lakehouse/repository/entities/main.py
def write_stream(self, tables: Iterable[pa.Table]) -> int:
    """Append `JOURNAL_SCHEMA` tables to parquet as one file per partition –
    the bulk imports' write
    ([`write_stream`][ftm_lakehouse.storage.parquet.ParquetStore.write_stream]).

    Args:
        tables: Stream of packed statement tables.

    Returns:
        Number of rows written.
    """
    return self.statements.write_stream(tables)  # raises in api mode

writer(origin=None, role=None)

A bulk journal writer – inserts its tail on success, drops it on error.

Example
with repo.writer(origin="import") as writer:
    writer.add_entity(entity)

Parameters:

Name Type Description Default
origin str | None

Origin of the statements written through it.

None
role str | None

Who asserts them, for statements that carry no role.

None
Source code in ftm_lakehouse/repository/entities/main.py
def writer(
    self, origin: str | None = None, role: str | None = None
) -> BaseJournalWriter:
    """A bulk journal writer – inserts its tail on success, drops it on error.

    Example:
        ```python
        with repo.writer(origin="import") as writer:
            writer.add_entity(entity)
        ```

    Args:
        origin: Origin of the statements written through it.
        role: Who asserts them, for statements that carry no role.
    """
    return self._journal.writer(origin, role)

JobRepository

Job runs and their status, stored per job class – resolve the repository through the factory:

from ftm_lakehouse.operation.crawl import CrawlJob
from ftm_lakehouse.repository.factories import get_jobs

jobs = get_jobs("my_dataset", CrawlJob)
jobs.put(job)
jobs.get(run_id)

ftm_lakehouse.repository.JobRepository

Bases: DatasetHandle, Generic[J]

Repository for job run storage.

Persists job run data as JSON files, organized by job type and run ID.

Example
repo = JobRepository(dataset="my_data", uri="s3://bucket/dataset")

# Store a job run
repo.put(job)

# Get latest run for a job type
job = repo.latest(CrawlJob)

# Run a job with lifecycle management
with repo.run(job) as run:
    # Do work...
    run.save()  # Periodic save
# Job automatically stopped when context exits
Source code in ftm_lakehouse/repository/job.py
class JobRepository(DatasetHandle, Generic[J]):
    """
    Repository for job run storage.

    Persists job run data as JSON files,
    organized by job type and run ID.

    Example:
        ```python
        repo = JobRepository(dataset="my_data", uri="s3://bucket/dataset")

        # Store a job run
        repo.put(job)

        # Get latest run for a job type
        job = repo.latest(CrawlJob)

        # Run a job with lifecycle management
        with repo.run(job) as run:
            # Do work...
            run.save()  # Periodic save
        # Job automatically stopped when context exits
        ```
    """

    def __init__(self, dataset: str, uri: Uri, model: type[J]) -> None:
        super().__init__(dataset, uri)
        self.job_type = model.__name__
        self._store = get_store(self._store_uri, model=model)

    def put(self, job: JobModel) -> None:
        """Store a job run."""
        self._store.put(path.JOB_RUNS(self.job_type, job.run_id), job)

    def get(self, run_id: str) -> J:
        """Get a specific job run by type and run ID."""
        key = path.JOB_RUNS(self.job_type, run_id)
        return self._store.get(key)

    def latest(self) -> J | None:
        """
        Get the latest run for the configured job type (self.model).

        Jobs are sorted by run ID (which contains timestamp),
        so the latest is the last in alphabetical order.
        """
        for key in sorted(
            self._store.iterate_keys(prefix=path.JOB_RUNS[self.job_type]),
            reverse=True,
        ):
            return self._store.get(key)
        return None

    def iterate(self) -> Generator[J, None, None]:
        """Iterate all runs for the current job type."""
        yield from self._store.iterate_values(prefix=path.JOB_RUNS[self.job_type])

    @contextlib.contextmanager
    def run(self, job: J) -> Generator[JobRun[J], None, None]:
        """
        Get a context manager for running a job.

        The job is automatically started on entry and stopped on exit.
        If an exception occurs, it's recorded in the job's exc field.
        """
        run = JobRun(self, job)
        try:
            run.start()
            yield run
        except Exception as e:
            run.stop(e)
            raise
        finally:
            if job.running:  # Only stop if not already stopped
                run.stop()

    def delete(self, job: J) -> None:
        """Delete a job run."""
        key = path.JOB_RUNS(self.job_type, job.run_id)
        self._store.delete(key)
        self.log.warning("Deleted job run", job=job.name, run_id=job.run_id)

delete(job)

Delete a job run.

Source code in ftm_lakehouse/repository/job.py
def delete(self, job: J) -> None:
    """Delete a job run."""
    key = path.JOB_RUNS(self.job_type, job.run_id)
    self._store.delete(key)
    self.log.warning("Deleted job run", job=job.name, run_id=job.run_id)

get(run_id)

Get a specific job run by type and run ID.

Source code in ftm_lakehouse/repository/job.py
def get(self, run_id: str) -> J:
    """Get a specific job run by type and run ID."""
    key = path.JOB_RUNS(self.job_type, run_id)
    return self._store.get(key)

iterate()

Iterate all runs for the current job type.

Source code in ftm_lakehouse/repository/job.py
def iterate(self) -> Generator[J, None, None]:
    """Iterate all runs for the current job type."""
    yield from self._store.iterate_values(prefix=path.JOB_RUNS[self.job_type])

latest()

Get the latest run for the configured job type (self.model).

Jobs are sorted by run ID (which contains timestamp), so the latest is the last in alphabetical order.

Source code in ftm_lakehouse/repository/job.py
def latest(self) -> J | None:
    """
    Get the latest run for the configured job type (self.model).

    Jobs are sorted by run ID (which contains timestamp),
    so the latest is the last in alphabetical order.
    """
    for key in sorted(
        self._store.iterate_keys(prefix=path.JOB_RUNS[self.job_type]),
        reverse=True,
    ):
        return self._store.get(key)
    return None

put(job)

Store a job run.

Source code in ftm_lakehouse/repository/job.py
def put(self, job: JobModel) -> None:
    """Store a job run."""
    self._store.put(path.JOB_RUNS(self.job_type, job.run_id), job)

run(job)

Get a context manager for running a job.

The job is automatically started on entry and stopped on exit. If an exception occurs, it's recorded in the job's exc field.

Source code in ftm_lakehouse/repository/job.py
@contextlib.contextmanager
def run(self, job: J) -> Generator[JobRun[J], None, None]:
    """
    Get a context manager for running a job.

    The job is automatically started on entry and stopped on exit.
    If an exception occurs, it's recorded in the job's exc field.
    """
    run = JobRun(self, job)
    try:
        run.start()
        yield run
    except Exception as e:
        run.stop(e)
        raise
    finally:
        if job.running:  # Only stop if not already stopped
            run.stop()

DocumentRepository

The read side of the exported document metadata (documents.csv, per origin scope).

ftm_lakehouse.repository.DocumentRepository

Bases: DatasetHandle

Repository for documents to consume for clients.

This gathers Document entities of the files stored in the archive and compiles a streamable csv list of document metadata.

Format: id,checksum,name,mimetype,path,size,updated_at,public_url

The csv itself is written by the export sweep (ExportOperation), which already holds every entity. The row shape and reading the result back belong to DocumentsArtifact; this repository streams the written csv back.

Example
documents = DocumentRepository(dataset="my_data", uri="s3://bucket/dataset")

# Iterate through documents metadata
for document in documents.stream():
    print(document.public_url)  # use uri to download
Source code in ftm_lakehouse/repository/documents.py
class DocumentRepository(DatasetHandle):
    """
    Repository for documents to consume for clients.

    This gathers Document entities of the files stored in the archive and
    compiles a streamable csv list of document metadata.

    Format: id,checksum,name,mimetype,path,size,updated_at,public_url

    The csv itself is written by the export sweep
    ([`ExportOperation`][ftm_lakehouse.operation.export.ExportOperation]),
    which already holds every entity. The row shape and reading the result
    back belong to
    [`DocumentsArtifact`][ftm_lakehouse.repository.artifacts.DocumentsArtifact];
    this repository streams the written csv back.

    Example:
        ```python
        documents = DocumentRepository(dataset="my_data", uri="s3://bucket/dataset")

        # Iterate through documents metadata
        for document in documents.stream():
            print(document.public_url)  # use uri to download
        ```
    """

    @cached_property
    def _artifact(self) -> DocumentsArtifact:
        """The documents export artifact for this dataset."""
        return DocumentsArtifact(self)

    @property
    def compression(self) -> CompressKind | None:
        """Compression codec of the exported artifacts (the dataset's config)."""
        return self._model.compression

    def csv_uri(self, origin: str | None = None) -> Uri:
        """Uri of the exported documents csv, optionally scoped to ``origin``."""
        return self._artifact[origin].uri

    def csv_key(self, origin: str | None = None) -> StoreKey:
        """Store key of the exported documents csv, carrying the dataset's codec."""
        return self._artifact[origin].key

    def stream(self, origin: str | None = None) -> Documents:
        """Stream the exported documents csv, optionally scoped to ``origin``."""
        yield from self._artifact[origin].stream()

compression property

Compression codec of the exported artifacts (the dataset's config).

csv_key(origin=None)

Store key of the exported documents csv, carrying the dataset's codec.

Source code in ftm_lakehouse/repository/documents.py
def csv_key(self, origin: str | None = None) -> StoreKey:
    """Store key of the exported documents csv, carrying the dataset's codec."""
    return self._artifact[origin].key

csv_uri(origin=None)

Uri of the exported documents csv, optionally scoped to origin.

Source code in ftm_lakehouse/repository/documents.py
def csv_uri(self, origin: str | None = None) -> Uri:
    """Uri of the exported documents csv, optionally scoped to ``origin``."""
    return self._artifact[origin].uri

stream(origin=None)

Stream the exported documents csv, optionally scoped to origin.

Source code in ftm_lakehouse/repository/documents.py
def stream(self, origin: str | None = None) -> Documents:
    """Stream the exported documents csv, optionally scoped to ``origin``."""
    yield from self._artifact[origin].stream()

ArtifactsRepository

The export artifacts of one dataset – statements.csv, entities.ftm.json, documents.csv (one per origin scope), parents.csv, statistics.json, index.json – and the diff series of the diffable ones.

from ftm_lakehouse.repository import get_artifacts

artifacts = get_artifacts("my_dataset")
artifacts.entities.exists()
artifacts.documents["crawl"].key

ftm_lakehouse.repository.ArtifactsRepository

Bases: DatasetHandle

The export artifacts of one dataset.

Example
artifacts = ArtifactsRepository("my_dataset", uri)
artifacts.entities.exists()
artifacts.documents["crawl"].key
Source code in ftm_lakehouse/repository/artifacts.py
class ArtifactsRepository(DatasetHandle):
    """The export artifacts of one dataset.

    Example:
        ```python
        artifacts = ArtifactsRepository("my_dataset", uri)
        artifacts.entities.exists()
        artifacts.documents["crawl"].key
        ```
    """

    @property
    def statements(self) -> StatementsArtifact:
        return StatementsArtifact(self)

    @property
    def entities(self) -> EntitiesArtifact:
        return EntitiesArtifact(self)

    @property
    def documents(self) -> DocumentsArtifact:
        return DocumentsArtifact(self)

    @property
    def parents(self) -> ParentsArtifact:
        return ParentsArtifact(self)

    @property
    def statistics(self) -> StatisticsArtifact:
        return StatisticsArtifact(self)

    @property
    def index(self) -> IndexArtifact:
        return IndexArtifact(self)

    def streamed(self) -> Iterator[Artifact]:
        """Every artifact the sweep writes – all but ``index.json`` – with one
        documents scope per `DOCUMENT_ORIGINS`."""
        yield self.statements
        yield self.entities
        yield self.parents  # ahead of the documents scopes – see `runs`
        for origin in DOCUMENT_ORIGINS:
            yield self.documents[origin]
        yield self.statistics

    def runs(self, now: datetime, parts: str) -> tuple[ArtifactRun, ...]:
        """One run per streamed artifact.

        Args:
            now: When the export started – diff files are named after it.
            parts: Directory the runs write their parts into.
        """
        runs = tuple(a.run(now, parts) for a in self.streamed())
        # the parents run writes the documents scopes' rows – first in
        # `streamed`, so ahead of their DELs
        for run in runs:
            if isinstance(run, ParentsRun):
                run.scopes = {r.name: r for r in runs if isinstance(r, DocumentsRun)}
        return runs

    def resources(self) -> Iterator[DataResource]:
        """Describe every written streamed artifact for ``index.json``."""
        public_prefix = self._model.get_public_prefix()
        if not public_prefix:
            return
        for artifact in self.streamed():
            resource = artifact.make_resource(public_prefix)
            if resource is not None:
                yield resource

resources()

Describe every written streamed artifact for index.json.

Source code in ftm_lakehouse/repository/artifacts.py
def resources(self) -> Iterator[DataResource]:
    """Describe every written streamed artifact for ``index.json``."""
    public_prefix = self._model.get_public_prefix()
    if not public_prefix:
        return
    for artifact in self.streamed():
        resource = artifact.make_resource(public_prefix)
        if resource is not None:
            yield resource

runs(now, parts)

One run per streamed artifact.

Parameters:

Name Type Description Default
now datetime

When the export started – diff files are named after it.

required
parts str

Directory the runs write their parts into.

required
Source code in ftm_lakehouse/repository/artifacts.py
def runs(self, now: datetime, parts: str) -> tuple[ArtifactRun, ...]:
    """One run per streamed artifact.

    Args:
        now: When the export started – diff files are named after it.
        parts: Directory the runs write their parts into.
    """
    runs = tuple(a.run(now, parts) for a in self.streamed())
    # the parents run writes the documents scopes' rows – first in
    # `streamed`, so ahead of their DELs
    for run in runs:
        if isinstance(run, ParentsRun):
            run.scopes = {r.name: r for r in runs if isinstance(r, DocumentsRun)}
    return runs

streamed()

Every artifact the sweep writes – all but index.json – with one documents scope per DOCUMENT_ORIGINS.

Source code in ftm_lakehouse/repository/artifacts.py
def streamed(self) -> Iterator[Artifact]:
    """Every artifact the sweep writes – all but ``index.json`` – with one
    documents scope per `DOCUMENT_ORIGINS`."""
    yield self.statements
    yield self.entities
    yield self.parents  # ahead of the documents scopes – see `runs`
    for origin in DOCUMENT_ORIGINS:
        yield self.documents[origin]
    yield self.statistics

An artifact is a stateless declaration bound to a dataset; what is true only during an export – open writers, the diff window, counts – lives on its run, which ExportSession drives.

ftm_lakehouse.repository.artifacts.Artifact

One export artifact, bound to a dataset.

Parameters:

Name Type Description Default
dataset DatasetHandle

The dataset handle the artifact belongs to.

required
origin str | None

Origin this variant is scoped to, None for all.

None
Source code in ftm_lakehouse/repository/artifacts.py
class Artifact:
    """One export artifact, bound to a dataset.

    Args:
        dataset: The dataset handle the artifact belongs to.
        origin: Origin this variant is scoped to, ``None`` for all.
    """

    base: ClassVar[StoreKey]
    kind: ClassVar[ExportKind]
    mime_type: ClassVar[str] = CSV
    compressed: ClassVar[bool] = True
    fieldnames: ClassVar[list[str] | None] = None

    def __init__(self, dataset: DatasetHandle, origin: str | None = None) -> None:
        self.dataset = dataset
        self.origin = validate_origin(origin) if origin else None

    def __repr__(self) -> str:
        return f"<{type(self).__name__}({self.key})>"

    def __getitem__(self, origin: str | None = None) -> Self:
        """The variant scoped to one origin – ``documents["crawl"]``."""
        return type(self)(self.dataset, origin)

    @property
    def name(self) -> str:
        """How this variant names itself in a result – ``crawl_documents``."""
        if self.origin:
            return f"{self.origin}_{self.kind}"
        return str(self.kind)

    @property
    def compression(self) -> CompressKind | None:
        """The dataset's codec, or ``None`` where the artifact takes none."""
        if not self.compressed:
            return None
        return self.dataset._model.compression

    @property
    def tag(self) -> StoreKey:
        """The codec-free key: what the freshness tag is named after."""
        return self.base[self.origin]

    @property
    def key(self) -> StoreKey:
        """The key the artifact actually lives at, codec included."""
        return self.tag + self.compression

    @property
    def uri(self) -> str:
        """Full uri of the artifact in the dataset's store."""
        return self.dataset._store.to_uri(self.key)

    @property
    def format(self) -> Formats:
        return "csv" if self.mime_type == CSV else "json"

    def exists(self) -> bool:
        """Whether the artifact has been written."""
        return self.dataset._store.exists(self.key)

    def touch(self, ts: datetime | None = None) -> None:
        """Stamp the freshness tag."""
        self.dataset._tags.set(self.tag, ts)

    def part(self, parts: str) -> str:
        """Where this artifact's part of a run is written, inside ``parts``."""
        return f"{parts}/{self.name}"

    def writer(self, parts: str | None = None, key: str | None = None) -> Writer:
        """A writer for the artifact (at ``key``, its own by default), or for its
        part of a run – lazy, so an unwritten part is never appended."""
        return Writer(
            (
                self.part(parts)
                if parts
                else self.dataset._store.to_uri(key or self.key)
            ),
            output_format=self.format,
            compression=self.compression,
            fieldnames=self.fieldnames,
            lazy=parts is not None,
        )

    def assembly(self) -> "Assembly":
        """This artifact's file, assembled from a run's parts – eager: a run that
        wrote nothing still replaces a stale file with an empty one."""
        return Assembly(self.dataset._store, self.key, self._write_empty)

    def _write_empty(self, key: str) -> None:
        """Write an empty artifact – just the header when the columns are known."""
        writer = self.writer(key=key)
        writer.open()
        writer.close()

    @contextmanager
    def reader(self, mode: str = "rb") -> Generator[IO[Any], None, None]:
        """Open the artifact for reading, decoded with the dataset's codec."""
        with self.dataset._store.open(
            self.key, mode, compression=self.compression
        ) as fh:
            yield fh

    def make_resource(self, public_prefix: str | None = None) -> DataResource | None:
        """Describe the artifact for ``index.json``, or ``None`` if unwritten."""
        if not self.exists():
            return None
        public_prefix = public_prefix or self.dataset._model.get_public_prefix()
        if not public_prefix:
            return None
        info = self.dataset._store.info(self.key)
        return DataResource(
            name=info.name,
            url=join_uri(public_prefix, self.key),
            checksum=self.dataset._store.checksum(self.key, CHECKSUM_ALGORITHM),
            timestamp=info.created_at,
            mime_type=self.mime_type,
            size=info.size,
        )

    def run(self, now: datetime, parts: str) -> "ArtifactRun":
        """The per-run object that writes this artifact's part into ``parts``."""
        return ArtifactRun(self, now, parts)

compression property

The dataset's codec, or None where the artifact takes none.

key property

The key the artifact actually lives at, codec included.

name property

How this variant names itself in a result – crawl_documents.

tag property

The codec-free key: what the freshness tag is named after.

uri property

Full uri of the artifact in the dataset's store.

__getitem__(origin=None)

The variant scoped to one origin – documents["crawl"].

Source code in ftm_lakehouse/repository/artifacts.py
def __getitem__(self, origin: str | None = None) -> Self:
    """The variant scoped to one origin – ``documents["crawl"]``."""
    return type(self)(self.dataset, origin)

assembly()

This artifact's file, assembled from a run's parts – eager: a run that wrote nothing still replaces a stale file with an empty one.

Source code in ftm_lakehouse/repository/artifacts.py
def assembly(self) -> "Assembly":
    """This artifact's file, assembled from a run's parts – eager: a run that
    wrote nothing still replaces a stale file with an empty one."""
    return Assembly(self.dataset._store, self.key, self._write_empty)

exists()

Whether the artifact has been written.

Source code in ftm_lakehouse/repository/artifacts.py
def exists(self) -> bool:
    """Whether the artifact has been written."""
    return self.dataset._store.exists(self.key)

make_resource(public_prefix=None)

Describe the artifact for index.json, or None if unwritten.

Source code in ftm_lakehouse/repository/artifacts.py
def make_resource(self, public_prefix: str | None = None) -> DataResource | None:
    """Describe the artifact for ``index.json``, or ``None`` if unwritten."""
    if not self.exists():
        return None
    public_prefix = public_prefix or self.dataset._model.get_public_prefix()
    if not public_prefix:
        return None
    info = self.dataset._store.info(self.key)
    return DataResource(
        name=info.name,
        url=join_uri(public_prefix, self.key),
        checksum=self.dataset._store.checksum(self.key, CHECKSUM_ALGORITHM),
        timestamp=info.created_at,
        mime_type=self.mime_type,
        size=info.size,
    )

part(parts)

Where this artifact's part of a run is written, inside parts.

Source code in ftm_lakehouse/repository/artifacts.py
def part(self, parts: str) -> str:
    """Where this artifact's part of a run is written, inside ``parts``."""
    return f"{parts}/{self.name}"

reader(mode='rb')

Open the artifact for reading, decoded with the dataset's codec.

Source code in ftm_lakehouse/repository/artifacts.py
@contextmanager
def reader(self, mode: str = "rb") -> Generator[IO[Any], None, None]:
    """Open the artifact for reading, decoded with the dataset's codec."""
    with self.dataset._store.open(
        self.key, mode, compression=self.compression
    ) as fh:
        yield fh

run(now, parts)

The per-run object that writes this artifact's part into parts.

Source code in ftm_lakehouse/repository/artifacts.py
def run(self, now: datetime, parts: str) -> "ArtifactRun":
    """The per-run object that writes this artifact's part into ``parts``."""
    return ArtifactRun(self, now, parts)

touch(ts=None)

Stamp the freshness tag.

Source code in ftm_lakehouse/repository/artifacts.py
def touch(self, ts: datetime | None = None) -> None:
    """Stamp the freshness tag."""
    self.dataset._tags.set(self.tag, ts)

writer(parts=None, key=None)

A writer for the artifact (at key, its own by default), or for its part of a run – lazy, so an unwritten part is never appended.

Source code in ftm_lakehouse/repository/artifacts.py
def writer(self, parts: str | None = None, key: str | None = None) -> Writer:
    """A writer for the artifact (at ``key``, its own by default), or for its
    part of a run – lazy, so an unwritten part is never appended."""
    return Writer(
        (
            self.part(parts)
            if parts
            else self.dataset._store.to_uri(key or self.key)
        ),
        output_format=self.format,
        compression=self.compression,
        fieldnames=self.fieldnames,
        lazy=parts is not None,
    )

ftm_lakehouse.repository.artifacts.DiffableArtifact

Bases: Artifact

An artifact with a diff series: its files and its stored {timestamp}:{version} state. The series directory carries no codec, so it does not move when the dataset's compression does.

Source code in ftm_lakehouse/repository/artifacts.py
class DiffableArtifact(Artifact):
    """An artifact with a diff series: its files and its stored
    ``{timestamp}:{version}`` state. The series directory carries no codec, so
    it does not move when the dataset's compression does."""

    diffs: ClassVar[DateTimeKey]

    @property
    def series(self) -> DateTimeKey:
        """The directory this variant's diff files live in."""
        return self.diffs[self.origin]

    @property
    def state_key(self) -> str:
        """Tag key the ``{timestamp}:{version}`` state is stored under."""
        return f"{self.series}-current"

    def get_state(self) -> tuple[datetime, int] | None:
        """Last diff state as ``(timestamp, delta table version)``."""
        state = self.dataset._tags.get(self.state_key)
        if state is None:
            return None
        ts_str, main_v = state.split(":")
        return (
            datetime.strptime(ts_str, path.TS_FORMAT).replace(tzinfo=timezone.utc),
            int(main_v),
        )

    def set_state(self, ts: datetime, version: int) -> None:
        """Store the diff state the next run is taken against."""
        ts_str = ts.strftime(path.TS_FORMAT)
        self.dataset._tags.put(self.state_key, f"{ts_str}:{version}")

    def diff_part(self, parts: str) -> str:
        """Where this series' part of a run's diff is written."""
        return f"{self.part(parts)}.diff"

    def diff_writer(self, parts: str) -> Writer:
        """A lazy writer for this series' part of a run's diff file."""
        fieldnames = ["op", *self.fieldnames] if self.fieldnames else None
        return Writer(
            self.diff_part(parts),
            output_format=self.format,
            compression=self.compression,
            fieldnames=fieldnames,
            lazy=True,
        )

    def diff_assembly(self, ts: datetime) -> "Assembly":
        """This run's diff file, assembled like `assembly` – but lazy: a window
        without changes leaves no file."""
        return Assembly(self.dataset._store, self.series(ts) + self.compression)

series property

The directory this variant's diff files live in.

state_key property

Tag key the {timestamp}:{version} state is stored under.

diff_assembly(ts)

This run's diff file, assembled like assembly – but lazy: a window without changes leaves no file.

Source code in ftm_lakehouse/repository/artifacts.py
def diff_assembly(self, ts: datetime) -> "Assembly":
    """This run's diff file, assembled like `assembly` – but lazy: a window
    without changes leaves no file."""
    return Assembly(self.dataset._store, self.series(ts) + self.compression)

diff_part(parts)

Where this series' part of a run's diff is written.

Source code in ftm_lakehouse/repository/artifacts.py
def diff_part(self, parts: str) -> str:
    """Where this series' part of a run's diff is written."""
    return f"{self.part(parts)}.diff"

diff_writer(parts)

A lazy writer for this series' part of a run's diff file.

Source code in ftm_lakehouse/repository/artifacts.py
def diff_writer(self, parts: str) -> Writer:
    """A lazy writer for this series' part of a run's diff file."""
    fieldnames = ["op", *self.fieldnames] if self.fieldnames else None
    return Writer(
        self.diff_part(parts),
        output_format=self.format,
        compression=self.compression,
        fieldnames=fieldnames,
        lazy=True,
    )

get_state()

Last diff state as (timestamp, delta table version).

Source code in ftm_lakehouse/repository/artifacts.py
def get_state(self) -> tuple[datetime, int] | None:
    """Last diff state as ``(timestamp, delta table version)``."""
    state = self.dataset._tags.get(self.state_key)
    if state is None:
        return None
    ts_str, main_v = state.split(":")
    return (
        datetime.strptime(ts_str, path.TS_FORMAT).replace(tzinfo=timezone.utc),
        int(main_v),
    )

set_state(ts, version)

Store the diff state the next run is taken against.

Source code in ftm_lakehouse/repository/artifacts.py
def set_state(self, ts: datetime, version: int) -> None:
    """Store the diff state the next run is taken against."""
    ts_str = ts.strftime(path.TS_FORMAT)
    self.dataset._tags.put(self.state_key, f"{ts_str}:{version}")

ftm_lakehouse.repository.artifacts.DocumentsArtifact

Bases: DiffableArtifact

Document metadata and its diff series, scoped per origin.

Source code in ftm_lakehouse/repository/artifacts.py
class DocumentsArtifact(DiffableArtifact):
    """Document metadata and its diff series, scoped per origin."""

    base = path.EXPORTS_DOCUMENTS
    kind = ExportKind.documents
    diffs = path.DIFFS_DOCUMENTS
    fieldnames = DOCUMENT_FIELDNAMES

    @staticmethod
    def is_document_schema(schema: str | None) -> bool:
        """Whether the documents export carries ``schema``: a ``Document``
        descendant other than ``Folder`` – shared by the live and delete paths."""
        if not schema:
            return False
        schema_ = model.get(str(schema))
        return (
            schema_ is not None
            and schema_.is_a("Document")
            and schema_.name != "Folder"
        )

    @staticmethod
    def is_parent_schema(schema: str | None) -> bool:
        """Whether an entity of ``schema`` can be a document's parent folder."""
        return schema in FOLDER_SCHEMATA

    @staticmethod
    def is_document(data: SDict) -> bool:
        """Whether an entity dict belongs in the documents export: a document
        schema (`is_document_schema`) with a content hash."""
        if not DocumentsArtifact.is_document_schema(data.get("schema")):
            return False
        return bool(data.get("properties", {}).get("contentHash"))

    def make_document(self, data: SDict, public_prefix: str | None = None) -> Document:
        """The row an entity dict contributes, ``path`` unset until the folder
        tree is known.

        Args:
            data: Entity dict, as `EntityPayload.to_dict` returns.
            public_prefix: Public url prefix to build blob links against.
        """
        document = Document.from_entity_dict(data)
        if public_prefix:
            document.public_url = join_uri(
                public_prefix, path.ArchiveKey(document.checksum).blob
            )
        return document

    def stream(self) -> Documents:
        """Stream this variant's csv back as `Document` models."""
        if not self.exists():
            return
        with self.reader("r") as raw:
            for row in csv.DictReader(raw):
                # csv values arrive as strings; pydantic coerces size / updated_at
                yield Document(**cast(dict[str, Any], row))

    def run(self, now: datetime, parts: str) -> "DocumentsRun":
        return DocumentsRun(self, now, parts)

is_document(data) staticmethod

Whether an entity dict belongs in the documents export: a document schema (is_document_schema) with a content hash.

Source code in ftm_lakehouse/repository/artifacts.py
@staticmethod
def is_document(data: SDict) -> bool:
    """Whether an entity dict belongs in the documents export: a document
    schema (`is_document_schema`) with a content hash."""
    if not DocumentsArtifact.is_document_schema(data.get("schema")):
        return False
    return bool(data.get("properties", {}).get("contentHash"))

is_document_schema(schema) staticmethod

Whether the documents export carries schema: a Document descendant other than Folder – shared by the live and delete paths.

Source code in ftm_lakehouse/repository/artifacts.py
@staticmethod
def is_document_schema(schema: str | None) -> bool:
    """Whether the documents export carries ``schema``: a ``Document``
    descendant other than ``Folder`` – shared by the live and delete paths."""
    if not schema:
        return False
    schema_ = model.get(str(schema))
    return (
        schema_ is not None
        and schema_.is_a("Document")
        and schema_.name != "Folder"
    )

is_parent_schema(schema) staticmethod

Whether an entity of schema can be a document's parent folder.

Source code in ftm_lakehouse/repository/artifacts.py
@staticmethod
def is_parent_schema(schema: str | None) -> bool:
    """Whether an entity of ``schema`` can be a document's parent folder."""
    return schema in FOLDER_SCHEMATA

make_document(data, public_prefix=None)

The row an entity dict contributes, path unset until the folder tree is known.

Parameters:

Name Type Description Default
data SDict

Entity dict, as EntityPayload.to_dict returns.

required
public_prefix str | None

Public url prefix to build blob links against.

None
Source code in ftm_lakehouse/repository/artifacts.py
def make_document(self, data: SDict, public_prefix: str | None = None) -> Document:
    """The row an entity dict contributes, ``path`` unset until the folder
    tree is known.

    Args:
        data: Entity dict, as `EntityPayload.to_dict` returns.
        public_prefix: Public url prefix to build blob links against.
    """
    document = Document.from_entity_dict(data)
    if public_prefix:
        document.public_url = join_uri(
            public_prefix, path.ArchiveKey(document.checksum).blob
        )
    return document

stream()

Stream this variant's csv back as Document models.

Source code in ftm_lakehouse/repository/artifacts.py
def stream(self) -> Documents:
    """Stream this variant's csv back as `Document` models."""
    if not self.exists():
        return
    with self.reader("r") as raw:
        for row in csv.DictReader(raw):
            # csv values arrive as strings; pydantic coerces size / updated_at
            yield Document(**cast(dict[str, Any], row))