Skip to content

Layer 3: Repository

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

ArchiveRepository

Content-addressed file archive with metadata and extracted text storage.

from ftm_lakehouse import get_archive

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

ftm_lakehouse.repository.ArchiveRepository

Bases: DatasetHandle

Repository for file archive operations.

Combines content-addressed blob storage (raw bytes) and model-based metadata storage (JSON) to provide file archiving.

Blobs are stored once per checksum, but each unique source path creates its own metadata file (keyed by File.id).

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

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

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

# Retrieve file info
file = archive.get_file(checksum)

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

    Combines content-addressed blob storage (raw bytes) and model-based
    metadata storage (JSON) to provide file archiving.

    Blobs are stored once per checksum, but each unique source path
    creates its own metadata file (keyed by File.id).

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

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

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

        # Retrieve file info
        file = archive.get_file(checksum)

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

    def __init__(self, dataset: str, uri: Uri) -> None:
        super().__init__(dataset, uri)
        self._files = get_store(self._store_uri, model=File, raise_on_nonexist=True)
        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 get_file(self, checksum: str, file_id: str | None = None) -> File:
        """
        Get file metadata for the given checksum.

        Args:
            checksum: SHA256 checksum of file
            file_id: Optional File.id to get specific metadata

        Raises:
            FileNotFoundError: When no metadata file exists
        """
        if file_id is not None:
            key = path.ArchiveKey(checksum).meta(file_id)
            return self._files.get(key)

        # Return first found metadata
        for file in self.get_all_files(checksum):
            return file
        raise FileNotFoundError(checksum)

    def get_all_files(self, checksum: str) -> Files:
        """
        Iterate all metadata files for the given checksum.

        Multiple crawlers may have archived the same file content from
        different source paths, each creating their own metadata file.
        """
        prefix = path.ArchiveKey(checksum)
        yield from self._files.iterate_values(prefix, glob="*.json")

    def iterate_files(self) -> Files:
        """Iterate all file metadata in the archive."""
        yield from self._files.iterate_values(path.ARCHIVE, glob="**/*.json")

    def put_file(self, file: File) -> File:
        """Store file metadata object."""
        file.store = str(self.uri)
        file.dataset = self.dataset
        self._files.put(file.meta_path, file)
        return file

    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,
        file: File | None = None,
        checksum: str | None = None,
        tag_updated: bool = True,
        store_metadata: bool = True,
        **metadata: Any,
    ) -> File:
        """
        Archive a file from a local or remote URI.

        The blob is stored once per checksum, but each unique source path
        creates its own metadata file (keyed by File.id).

        Args:
            uri: Local or remote URI to the file
            file: Optional metadata file object to patch
            checksum: Content hash (skip computation if provided)
            **metadata: Additional data to store in file's extra field, including
                FollowTheMoney properties for the `Document` schema

        Returns:
            File metadata object
        """
        resource = UriResource(uri)

        # store bytes blob (skipped if already exists)
        checksum = self.store_blob(uri, checksum)

        # file metadata
        if file is None:
            info = resource.info()
            file = File.from_info(info, checksum)

        # patch File from given file props
        file_props: dict[str, Any] = {}
        for key in list(metadata.keys()):
            if key in file.__class__.model_fields:
                file_props[key] = metadata.pop(key)
        file = File(**dict_merge(file.model_dump(), file_props))
        # add remaining metadata
        file.extra = clean_dict(metadata)
        file.dataset = self.dataset
        file.origin = file.origin or ARCHIVE_ORIGIN

        if store_metadata:
            # Store metadata
            self._files.put(file.meta_path, file)
        if tag_updated:
            # Notify archive was updated
            self.touch()

        self.log.info(
            f"Archived `{file.key} ({file.checksum})`",
            checksum=file.checksum,
        )

        return file

    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 delete(self, file: File) -> None:
        """
        Delete a file's metadata from the archive.

        The blob is never deleted. (FIXME)
        """
        self.log.warning(
            "Deleting file metadata",
            checksum=file.checksum,
            file_id=file.id,
        )
        self._files.delete(file.meta_path)

    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"""
        key = join_relpaths(make_checksum_key(checksum), path)
        self._store.put(key, data)

    def get_data(self, checksum: str, path: str) -> bytes:
        """Get raw data at the given path"""
        key = join_relpaths(make_checksum_key(checksum), path)
        return self._store.get(key)

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

delete(file)

Delete a file's metadata from the archive.

The blob is never deleted. (FIXME)

Source code in ftm_lakehouse/repository/archive.py
def delete(self, file: File) -> None:
    """
    Delete a file's metadata from the archive.

    The blob is never deleted. (FIXME)
    """
    self.log.warning(
        "Deleting file metadata",
        checksum=file.checksum,
        file_id=file.id,
    )
    self._files.delete(file.meta_path)

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_all_files(checksum)

Iterate all metadata files for the given checksum.

Multiple crawlers may have archived the same file content from different source paths, each creating their own metadata file.

Source code in ftm_lakehouse/repository/archive.py
def get_all_files(self, checksum: str) -> Files:
    """
    Iterate all metadata files for the given checksum.

    Multiple crawlers may have archived the same file content from
    different source paths, each creating their own metadata file.
    """
    prefix = path.ArchiveKey(checksum)
    yield from self._files.iterate_values(prefix, glob="*.json")

get_data(checksum, path)

Get raw data at the given path

Source code in ftm_lakehouse/repository/archive.py
def get_data(self, checksum: str, path: str) -> bytes:
    """Get raw data at the given path"""
    key = join_relpaths(make_checksum_key(checksum), path)
    return self._store.get(key)

get_file(checksum, file_id=None)

Get file metadata for the given checksum.

Parameters:

Name Type Description Default
checksum str

SHA256 checksum of file

required
file_id str | None

Optional File.id to get specific metadata

None

Raises:

Type Description
FileNotFoundError

When no metadata file exists

Source code in ftm_lakehouse/repository/archive.py
def get_file(self, checksum: str, file_id: str | None = None) -> File:
    """
    Get file metadata for the given checksum.

    Args:
        checksum: SHA256 checksum of file
        file_id: Optional File.id to get specific metadata

    Raises:
        FileNotFoundError: When no metadata file exists
    """
    if file_id is not None:
        key = path.ArchiveKey(checksum).meta(file_id)
        return self._files.get(key)

    # Return first found metadata
    for file in self.get_all_files(checksum):
        return file
    raise FileNotFoundError(checksum)

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_files()

Iterate all file metadata in the archive.

Source code in ftm_lakehouse/repository/archive.py
def iterate_files(self) -> Files:
    """Iterate all file metadata in the archive."""
    yield from self._files.iterate_values(path.ARCHIVE, glob="**/*.json")

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

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"""
    key = join_relpaths(make_checksum_key(checksum), path)
    self._store.put(key, data)

put_file(file)

Store file metadata object.

Source code in ftm_lakehouse/repository/archive.py
def put_file(self, file: File) -> File:
    """Store file metadata object."""
    file.store = str(self.uri)
    file.dataset = self.dataset
    self._files.put(file.meta_path, file)
    return file

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, file=None, checksum=None, tag_updated=True, store_metadata=True, **metadata)

Archive a file from a local or remote URI.

The blob is stored once per checksum, but each unique source path creates its own metadata file (keyed by File.id).

Parameters:

Name Type Description Default
uri Uri

Local or remote URI to the file

required
file File | None

Optional metadata file object to patch

None
checksum str | None

Content hash (skip computation if provided)

None
**metadata Any

Additional data to store in file's extra field, including FollowTheMoney properties for the Document schema

{}

Returns:

Type Description
File

File metadata object

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

    The blob is stored once per checksum, but each unique source path
    creates its own metadata file (keyed by File.id).

    Args:
        uri: Local or remote URI to the file
        file: Optional metadata file object to patch
        checksum: Content hash (skip computation if provided)
        **metadata: Additional data to store in file's extra field, including
            FollowTheMoney properties for the `Document` schema

    Returns:
        File metadata object
    """
    resource = UriResource(uri)

    # store bytes blob (skipped if already exists)
    checksum = self.store_blob(uri, checksum)

    # file metadata
    if file is None:
        info = resource.info()
        file = File.from_info(info, checksum)

    # patch File from given file props
    file_props: dict[str, Any] = {}
    for key in list(metadata.keys()):
        if key in file.__class__.model_fields:
            file_props[key] = metadata.pop(key)
    file = File(**dict_merge(file.model_dump(), file_props))
    # add remaining metadata
    file.extra = clean_dict(metadata)
    file.dataset = self.dataset
    file.origin = file.origin or ARCHIVE_ORIGIN

    if store_metadata:
        # Store metadata
        self._files.put(file.meta_path, file)
    if tag_updated:
        # Notify archive was updated
        self.touch()

    self.log.info(
        f"Archived `{file.key} ({file.checksum})`",
        checksum=file.checksum,
    )

    return file

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

Entity/statement operations combining JournalStore and ParquetStore.

from ftmq.query import M, 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(M(origin="import")))

ftm_lakehouse.repository.EntityRepository

Bases: DatasetHandle

Repository for entity/statement operations.

Combines JournalStore (write-ahead buffer) and ParquetStore (Delta Lake) to provide buffered statement storage with efficient querying.

Writes go to the journal first, then are flushed to the parquet store. Reads query the parquet store (optionally flushing first).

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

# Write entities
with repo.writer(origin="import") as writer:
    writer.add_entity(entity)

# Flush to parquet
repo.flush()

# Query entities
for entity in repo.query(Query(M(origin="import"))):
    process(entity)
Source code in ftm_lakehouse/repository/entities/main.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
class EntityRepository(DatasetHandle):
    """
    Repository for entity/statement operations.

    Combines JournalStore (write-ahead buffer) and ParquetStore (Delta Lake)
    to provide buffered statement storage with efficient querying.

    Writes go to the journal first, then are flushed to the parquet store.
    Reads query the parquet store (optionally flushing first).

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

        # Write entities
        with repo.writer(origin="import") as writer:
            writer.add_entity(entity)

        # Flush to parquet
        repo.flush()

        # Query entities
        for entity in repo.query(Query(M(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:
        """Local parquet store, built lazily – api instances never get one."""
        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, self.compression)

    @contextmanager
    def writer(
        self, origin: str | None = None, role: str | None = None
    ) -> Generator[BaseJournalWriter, None, None]:
        """Get a bulk writer for adding entities/statements.

        The writer owns its own lifecycle (insert the tail on success, drop
        the un-inserted buffer on error, close either way – see
        `BaseJournalWriter.__exit__`); this adds the freshness tag,
        stamped only when the block leaves cleanly.

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

        Args:
            origin: Origin tag for statements written through this writer.
            role: Default role – who is asserting these statements – for
                statements that carry none of their own.

        Yields:
            The journal writer, open for the duration of the block.
        """
        with (
            self._tags.touch(tag.JOURNAL_UPDATED),
            self._journal.writer(origin, role) as writer,
        ):
            yield writer

    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 statement store.

        The journal holds the parquet statement columns, so this streams Arrow
        batches from one store into the other via
        [`write_batches`][EntityRepository.write_batches]. Duplicates and
        tombstones land as new rows; call [`merge`][EntityRepository.merge]
        afterwards to collapse them.

        Returns:
            Number of statements appended.
        """
        with self._tags.touch(tag.JOURNAL_FLUSHED), 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),
            )
        elif not self._tags.exists(tag.STATEMENTS_OPTIMIZED):
            # initial run: give freshness comparisons a baseline. An empty
            # store is trivially canonical, and without the tag every
            # consumer keyed on it would re-run forever (`is_latest` is
            # False when no dependency exists at all).
            self._tags.set(tag.STATEMENTS_OPTIMIZED)
        return total

    @no_api
    def write_batches(self, tables: Iterable[pa.Table]) -> int:
        """Append packed Arrow tables to parquet – the one write loop.

        Every producer packs its own rows and hands them here: the journal
        drain (`JournalStore.flush_batches`), the safe bulk import
        (`flush_table`)
        and the unsafe one
        (`RowBuffer`). Tables
        arrive in `JOURNAL_SCHEMA` – no
        ``shard`` column, [`ParquetStore.append`][ParquetStore.append] derives it – and go
        straight there; one is durable before the producer is asked for the
        next, which is what lets the journal drop a segment it has handed
        over. Sizing is the producer's call: each table becomes one parquet
        file per ``(shard, bucket, origin)`` partition it spans, so bigger
        tables cost fewer files and fewer Delta commits.

        Args:
            tables: Stream of packed statement tables.

        Returns:
            Number of rows written.
        """
        total = 0
        for table in tables:
            if not table.num_rows:
                continue
            self._statements.append(table)
            total += table.num_rows
        return total

    def merge(self, force: bool = False) -> None:
        """Collapse duplicates and reap expired tombstones from parquet store.

        Flushes the journal first. ``force`` rewrites every partition
        regardless of freshness tags.
        """
        self.flush()
        self._statements.merge(force)

    @no_api
    def shard(self, shards: int) -> None:
        """Re-shard the parquet store onto ``shards`` entity-hash shards.

        Drains the journal first – a row left in it would be placed by
        whichever count the flushing store resolves, and only rows already
        in parquet are moved by the rewrite – then rewrites the store
        ([`ParquetStore.shard`][ParquetStore.shard]) and adopts the new count, so this
        instance keeps resolving reads and writes to the right shards.

        Only the storage half: the dataset's ``config.yml`` is what every
        *other* reader resolves the count from, and
        [`ShardOperation`][ftm_lakehouse.operation.maintenance.ShardOperation] writes
        it once this returns.

        Args:
            shards: Target shard count; ``<= 1`` means a single shard.
        """
        self.flush()
        self._statements.shard(shards)
        self.shards = shards

    @no_api
    def compact(self) -> None:
        """Bin-pack small parquet files within each partition."""
        self._statements.compact()

    @no_api
    def vacuum(self, retention_hours: int = 0) -> None:
        """Delete obsolete parquet files tombstoned in the Delta log."""
        self._statements.vacuum(retention_hours=retention_hours)

    @no_api
    def sweep(
        self, with_csv_export: bool = True, tee: bool = True
    ) -> Iterator[StatementDict]:
        """One scan of the store, optionally writing ``statements.csv`` from it.

        Delegates to [`ParquetStore.sweep`][ParquetStore.sweep] with this
        dataset's csv key, so the artifact carries the configured codec.

        Args:
            with_csv_export: Write the ``statements.csv`` artifact from the same
                Arrow batches the rows come from.
            tee: Yield row dicts. ``False`` keeps the scan columnar.

        Yields:
            ``StatementDict`` rows, unless ``tee`` is off.
        """
        key = self.EXPORTS_STATEMENTS if with_csv_export else None
        yield from self._statements.sweep(key, tee)

    @property
    @no_api
    def exists(self) -> bool:
        """Whether the statement store has been written – local only."""
        return self._statements.exists

    @property
    @no_api
    def needs_merge(self) -> bool:
        """Whether the statement store has writes that
        [`merge`][EntityRepository.merge] has not collapsed yet – local only.

        Reads are canonical only on a merged store, so anything publishing
        canonical rows (the exports, and their diffs strictly) checks
        this first. See [`ParquetStore.needs_merge`][ParquetStore.needs_merge].
        """
        return self._statements.needs_merge

    def query_statements_data(self, q: Query | None = None) -> Iterator[StatementDict]:
        """Query raw statement dicts from the parquet store.

        The fast read: no `LakehouseStatement` construction – use
        [`query_statements`][EntityRepository.query_statements] for model
        objects. Same execution strategy as
        [`query_statements`][EntityRepository.query_statements], so a sorted or
        sliced query still runs globally instead of once per partition.
        """
        yield from self._statements._statement_data(q)

    @no_api
    def evolve_schema(self) -> list[str]:
        """Add statement columns the parquet store was created without.

        Delegates to [`ParquetStore.evolve_schema`][ParquetStore.evolve_schema],
        the primitive behind the schema migrations.

        Returns:
            Names of the columns added – empty if the store is already current.
        """
        return self._statements.evolve_schema()

    @no_api
    def unlock(self) -> bool:
        """Forcibly release the dataset write fence.

        Delegates to [`ParquetStore.unlock`][ParquetStore.unlock]. Use as an operator
        escape hatch when a writer died with the lock held; do not
        invoke while a legitimate writer is still running.

        Returns:
            ``True`` if a lock was released, ``False`` otherwise.
        """
        return self._statements.unlock()

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

        Args:
            q: ftmq ``Query`` of entity-level filters (schema, properties, ...).
            flush_first: Flush the journal to parquet before querying.

        Yields:
            StatementEntity objects matching the query.
        """
        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 statements from the parquet store.

        Args:
            q: ftmq ``Query`` – filters plus ordering / slicing.
            flush_first: Flush the journal to parquet before querying.

        Yields:
            `LakehouseStatement` objects.
        """
        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 entities from the exported JSON file.

        This reads from the pre-exported entities.ftm.json file,
        not directly from the parquet store – decoded with the dataset's
        codec, since that artifact is written compressed when configured.
        """
        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:
        """Delete all statements for an entity via journal tombstones.

        Reads statements from both parquet and journal, then UPSERTs
        tombstone rows (with deleted_at set) into the journal. Each
        tombstone carries the live row's ``fragment`` and ``role`` – both
        are row identity, so a tombstone missing either lands in a different
        merge group and shadows nothing. Reading the live rows first is what
        makes that automatic: one tombstone per row means every role's
        assertion is deleted, which is what deleting the entity means.

        Args:
            entity_id: The entity ID to delete
            origin: Only delete entity data from this origin

        Returns:
            Number of tombstone statements written
        """
        now = utc_now()
        stmts = self._collect_entity_statements(entity_id)
        if not stmts:
            return 0
        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:
        """Delete a single statement via journal tombstone.

        Args:
            stmt: The Statement to delete. A
                `ftm_lakehouse.model.statement.LakehouseStatement` (e.g. read
                back via `ParquetStore.get_statements`) carries its own
                fragment and role.
            fragment: Fragment override – required to shadow a
                fragment-bearing row when passing a plain ``Statement``;
                leave unset otherwise.
            role: Role override – likewise required to shadow a row written
                under a role when passing 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 delete an entire origin – journal included.

        Unlike [`delete_entity`][EntityRepository.delete_entity] this writes no
        tombstones: ``origin`` is a partition column, so
        [`ParquetStore.delete_origin`][ParquetStore.delete_origin] drops whole
        partitions under the maintenance fence and the rows are gone at that
        commit – no merge, no grace period.

        The journal is flushed first, so rows written under ``origin`` and
        still buffered land in parquet in time to be dropped rather than
        resurrecting on the next [`flush`][EntityRepository.flush]. Flushing
        happens *outside* the fence – its append takes the shared side, which
        the exclusive one locks out – so a writer journalling into ``origin``
        during the drop still survives it. Stop the writers to be sure.

        Args:
            origin: The origin tag to drop.

        Raises:
            ValueError: If ``origin`` is not a safe origin name
                (see `validate_origin`).
            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)

    @no_api
    def _collect_entity_statements(self, entity_id: str) -> list[LakehouseStatement]:
        """Read all statements for an entity from parquet + journal.

        Uses shard-partitioned query for efficient single-entity lookup.
        Statements are keyed by
        `ftm_lakehouse.model.statement.LakehouseStatement.dedupe_key` – the
        same statement content under distinct fragments, origins or roles is
        distinct for tombstoning purposes, so each live row gets its own
        matching tombstone.
        """
        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

        # Read from journal (may override parquet entries) – typed columns,
        # so the entity filter runs in SQL.
        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())

    def stats(self) -> DatasetStats:
        """Compute statistics from the parquet store."""
        return self._statements.stats()

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

    @no_api
    def deleted_ids(self, since: datetime) -> Iterator[str]:
        """Entity ids with statements tombstoned since the given timestamp.

        Reads `ParquetStore.source_raw`, since the live view hides exactly
        the rows this asks about
        """
        q = Query(C(deleted_at__gte=since))
        return self._statements.get_entity_ids(q, source=self._statements.source_raw)

exists property

Whether the statement store has been written – local only.

needs_merge property

Whether the statement store has writes that merge has not collapsed yet – local only.

Reads are canonical only on a merged store, so anything publishing canonical rows (the exports, and their diffs strictly) checks this first. See ParquetStore.needs_merge.

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)

compact()

Bin-pack small parquet files within each partition.

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def compact(self) -> None:
    """Bin-pack small parquet files within each partition."""
    self._statements.compact()

delete_entity(entity_id, origin=None)

Delete all statements for an entity via journal tombstones.

Reads statements from both parquet and journal, then UPSERTs tombstone rows (with deleted_at set) into the journal. Each tombstone carries the live row's fragment and role – both are row identity, so a tombstone missing either lands in a different merge group and shadows nothing. Reading the live rows first is what makes that automatic: one tombstone per row means every role's assertion is deleted, which is what deleting the entity means.

Parameters:

Name Type Description Default
entity_id str

The entity ID to delete

required
origin str | None

Only delete entity data from this origin

None

Returns:

Type Description
int

Number of tombstone statements written

Source code in ftm_lakehouse/repository/entities/main.py
def delete_entity(self, entity_id: str, origin: str | None = None) -> int:
    """Delete all statements for an entity via journal tombstones.

    Reads statements from both parquet and journal, then UPSERTs
    tombstone rows (with deleted_at set) into the journal. Each
    tombstone carries the live row's ``fragment`` and ``role`` – both
    are row identity, so a tombstone missing either lands in a different
    merge group and shadows nothing. Reading the live rows first is what
    makes that automatic: one tombstone per row means every role's
    assertion is deleted, which is what deleting the entity means.

    Args:
        entity_id: The entity ID to delete
        origin: Only delete entity data from this origin

    Returns:
        Number of tombstone statements written
    """
    now = utc_now()
    stmts = self._collect_entity_statements(entity_id)
    if not stmts:
        return 0
    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 delete an entire origin – journal included.

Unlike delete_entity this writes no tombstones: origin is a partition column, so ParquetStore.delete_origin drops whole partitions under the maintenance fence and the rows are gone at that commit – no merge, no grace period.

The journal is flushed first, so rows written under origin and still buffered land in parquet in time to be dropped rather than resurrecting on the next flush. Flushing happens outside the fence – its append takes the shared side, which the exclusive one locks out – so a writer journalling into origin during the drop still survives it. Stop the writers to be sure.

Parameters:

Name Type Description Default
origin str

The origin tag to drop.

required

Raises:

Type Description
ValueError

If origin is not a safe origin name (see validate_origin).

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 delete an entire origin – journal included.

    Unlike [`delete_entity`][EntityRepository.delete_entity] this writes no
    tombstones: ``origin`` is a partition column, so
    [`ParquetStore.delete_origin`][ParquetStore.delete_origin] drops whole
    partitions under the maintenance fence and the rows are gone at that
    commit – no merge, no grace period.

    The journal is flushed first, so rows written under ``origin`` and
    still buffered land in parquet in time to be dropped rather than
    resurrecting on the next [`flush`][EntityRepository.flush]. Flushing
    happens *outside* the fence – its append takes the shared side, which
    the exclusive one locks out – so a writer journalling into ``origin``
    during the drop still survives it. Stop the writers to be sure.

    Args:
        origin: The origin tag to drop.

    Raises:
        ValueError: If ``origin`` is not a safe origin name
            (see `validate_origin`).
        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)

Delete a single statement via journal tombstone.

Parameters:

Name Type Description Default
stmt Statement

The Statement to delete. A ftm_lakehouse.model.statement.LakehouseStatement (e.g. read back via ParquetStore.get_statements) carries its own fragment and role.

required
fragment str | None

Fragment override – required to shadow a fragment-bearing row when passing a plain Statement; leave unset otherwise.

None
role str | None

Role override – likewise required to shadow a row written under a role when passing 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:
    """Delete a single statement via journal tombstone.

    Args:
        stmt: The Statement to delete. A
            `ftm_lakehouse.model.statement.LakehouseStatement` (e.g. read
            back via `ParquetStore.get_statements`) carries its own
            fragment and role.
        fragment: Fragment override – required to shadow a
            fragment-bearing row when passing a plain ``Statement``;
            leave unset otherwise.
        role: Role override – likewise required to shadow a row written
            under a role when passing a plain ``Statement``.
    """
    with self.writer() as w:
        w.add_statement(stmt, deleted_at=utc_now(), fragment=fragment, role=role)

deleted_ids(since)

Entity ids with statements tombstoned since the given timestamp.

Reads ParquetStore.source_raw, since the live view hides exactly the rows this asks about

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def deleted_ids(self, since: datetime) -> Iterator[str]:
    """Entity ids with statements tombstoned since the given timestamp.

    Reads `ParquetStore.source_raw`, since the live view hides exactly
    the rows this asks about
    """
    q = Query(C(deleted_at__gte=since))
    return self._statements.get_entity_ids(q, source=self._statements.source_raw)

evolve_schema()

Add statement columns the parquet store was created without.

Delegates to ParquetStore.evolve_schema, the primitive behind the schema migrations.

Returns:

Type Description
list[str]

Names of the columns added – empty if the store is already current.

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def evolve_schema(self) -> list[str]:
    """Add statement columns the parquet store was created without.

    Delegates to [`ParquetStore.evolve_schema`][ParquetStore.evolve_schema],
    the primitive behind the schema migrations.

    Returns:
        Names of the columns added – empty if the store is already current.
    """
    return self._statements.evolve_schema()

flush()

Drain the journal into the parquet statement store.

The journal holds the parquet statement columns, so this streams Arrow batches from one store into the other via write_batches. Duplicates and tombstones land as new rows; call merge afterwards to collapse them.

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 statement store.

    The journal holds the parquet statement columns, so this streams Arrow
    batches from one store into the other via
    [`write_batches`][EntityRepository.write_batches]. Duplicates and
    tombstones land as new rows; call [`merge`][EntityRepository.merge]
    afterwards to collapse them.

    Returns:
        Number of statements appended.
    """
    with self._tags.touch(tag.JOURNAL_FLUSHED), 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),
        )
    elif not self._tags.exists(tag.STATEMENTS_OPTIMIZED):
        # initial run: give freshness comparisons a baseline. An empty
        # store is trivially canonical, and without the tag every
        # consumer keyed on it would re-run forever (`is_latest` is
        # False when no dependency exists at all).
        self._tags.set(tag.STATEMENTS_OPTIMIZED)
    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)

Collapse duplicates and reap expired tombstones from parquet store.

Flushes the journal first. force rewrites every partition regardless of freshness tags.

Source code in ftm_lakehouse/repository/entities/main.py
def merge(self, force: bool = False) -> None:
    """Collapse duplicates and reap expired tombstones from parquet store.

    Flushes the journal first. ``force`` rewrites every partition
    regardless of freshness tags.
    """
    self.flush()
    self._statements.merge(force)

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

Query entities from the parquet store.

Parameters:

Name Type Description Default
q Query | None

ftmq Query of entity-level filters (schema, properties, ...).

None
flush_first bool

Flush the journal to parquet before querying.

False

Yields:

Type Description
StatementEntities

StatementEntity objects matching the query.

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: ftmq ``Query`` of entity-level filters (schema, properties, ...).
        flush_first: Flush the journal to parquet before querying.

    Yields:
        StatementEntity objects matching the query.
    """
    if flush_first:
        self.flush()
    yield from self._statements.query(q)

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

Query statements from the parquet store.

Parameters:

Name Type Description Default
q Query | None

ftmq Query – filters plus ordering / slicing.

None
flush_first bool

Flush the journal to parquet before querying.

False

Yields:

Type Description
Statements

LakehouseStatement objects.

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

    Args:
        q: ftmq ``Query`` – filters plus ordering / slicing.
        flush_first: Flush the journal to parquet before querying.

    Yields:
        `LakehouseStatement` objects.
    """
    if flush_first:
        self.flush()
    yield from self._statements.query_statements(q)

query_statements_data(q=None)

Query raw statement dicts from the parquet store.

The fast read: no LakehouseStatement construction – use query_statements for model objects. Same execution strategy as query_statements, so a sorted or sliced query still runs globally instead of once per partition.

Source code in ftm_lakehouse/repository/entities/main.py
def query_statements_data(self, q: Query | None = None) -> Iterator[StatementDict]:
    """Query raw statement dicts from the parquet store.

    The fast read: no `LakehouseStatement` construction – use
    [`query_statements`][EntityRepository.query_statements] for model
    objects. Same execution strategy as
    [`query_statements`][EntityRepository.query_statements], so a sorted or
    sliced query still runs globally instead of once per partition.
    """
    yield from self._statements._statement_data(q)

shard(shards)

Re-shard the parquet store onto shards entity-hash shards.

Drains the journal first – a row left in it would be placed by whichever count the flushing store resolves, and only rows already in parquet are moved by the rewrite – then rewrites the store (ParquetStore.shard) and adopts the new count, so this instance keeps resolving reads and writes to the right shards.

Only the storage half: the dataset's config.yml is what every other reader resolves the count from, and ShardOperation writes it once this returns.

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
@no_api
def shard(self, shards: int) -> None:
    """Re-shard the parquet store onto ``shards`` entity-hash shards.

    Drains the journal first – a row left in it would be placed by
    whichever count the flushing store resolves, and only rows already
    in parquet are moved by the rewrite – then rewrites the store
    ([`ParquetStore.shard`][ParquetStore.shard]) and adopts the new count, so this
    instance keeps resolving reads and writes to the right shards.

    Only the storage half: the dataset's ``config.yml`` is what every
    *other* reader resolves the count from, and
    [`ShardOperation`][ftm_lakehouse.operation.maintenance.ShardOperation] writes
    it once this returns.

    Args:
        shards: Target shard count; ``<= 1`` means a single shard.
    """
    self.flush()
    self._statements.shard(shards)
    self.shards = shards

stats()

Compute statistics from the parquet store.

Source code in ftm_lakehouse/repository/entities/main.py
def stats(self) -> DatasetStats:
    """Compute statistics from the parquet store."""
    return self._statements.stats()

stream()

Stream entities from the exported JSON file.

This reads from the pre-exported entities.ftm.json file, not directly from the parquet store – decoded with the dataset's codec, since that artifact is written compressed when configured.

Source code in ftm_lakehouse/repository/entities/main.py
def stream(self) -> ValueEntities:
    """
    Stream entities from the exported JSON file.

    This reads from the pre-exported entities.ftm.json file,
    not directly from the parquet store – decoded with the dataset's
    codec, since that artifact is written compressed when configured.
    """
    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)

sweep(with_csv_export=True, tee=True)

One scan of the store, optionally writing statements.csv from it.

Delegates to ParquetStore.sweep with this dataset's csv key, so the artifact carries the configured codec.

Parameters:

Name Type Description Default
with_csv_export bool

Write the statements.csv artifact from the same Arrow batches the rows come from.

True
tee bool

Yield row dicts. False keeps the scan columnar.

True

Yields:

Type Description
StatementDict

StatementDict rows, unless tee is off.

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def sweep(
    self, with_csv_export: bool = True, tee: bool = True
) -> Iterator[StatementDict]:
    """One scan of the store, optionally writing ``statements.csv`` from it.

    Delegates to [`ParquetStore.sweep`][ParquetStore.sweep] with this
    dataset's csv key, so the artifact carries the configured codec.

    Args:
        with_csv_export: Write the ``statements.csv`` artifact from the same
            Arrow batches the rows come from.
        tee: Yield row dicts. ``False`` keeps the scan columnar.

    Yields:
        ``StatementDict`` rows, unless ``tee`` is off.
    """
    key = self.EXPORTS_STATEMENTS if with_csv_export else None
    yield from self._statements.sweep(key, tee)

unlock()

Forcibly release the dataset write fence.

Delegates to ParquetStore.unlock. Use as an operator escape hatch when a writer died with the lock held; do not invoke while a legitimate writer is still running.

Returns:

Type Description
bool

True if a lock was released, False otherwise.

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def unlock(self) -> bool:
    """Forcibly release the dataset write fence.

    Delegates to [`ParquetStore.unlock`][ParquetStore.unlock]. Use as an operator
    escape hatch when a writer died with the lock held; do not
    invoke while a legitimate writer is still running.

    Returns:
        ``True`` if a lock was released, ``False`` otherwise.
    """
    return self._statements.unlock()

vacuum(retention_hours=0)

Delete obsolete parquet files tombstoned in the Delta log.

Source code in ftm_lakehouse/repository/entities/main.py
@no_api
def vacuum(self, retention_hours: int = 0) -> None:
    """Delete obsolete parquet files tombstoned in the Delta log."""
    self._statements.vacuum(retention_hours=retention_hours)

write_batches(tables)

Append packed Arrow tables to parquet – the one write loop.

Every producer packs its own rows and hands them here: the journal drain (JournalStore.flush_batches), the safe bulk import (flush_table) and the unsafe one (RowBuffer). Tables arrive in JOURNAL_SCHEMA – no shard column, ParquetStore.append derives it – and go straight there; one is durable before the producer is asked for the next, which is what lets the journal drop a segment it has handed over. Sizing is the producer's call: each table becomes one parquet file per (shard, bucket, origin) partition it spans, so bigger tables cost fewer files and fewer Delta commits.

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
@no_api
def write_batches(self, tables: Iterable[pa.Table]) -> int:
    """Append packed Arrow tables to parquet – the one write loop.

    Every producer packs its own rows and hands them here: the journal
    drain (`JournalStore.flush_batches`), the safe bulk import
    (`flush_table`)
    and the unsafe one
    (`RowBuffer`). Tables
    arrive in `JOURNAL_SCHEMA` – no
    ``shard`` column, [`ParquetStore.append`][ParquetStore.append] derives it – and go
    straight there; one is durable before the producer is asked for the
    next, which is what lets the journal drop a segment it has handed
    over. Sizing is the producer's call: each table becomes one parquet
    file per ``(shard, bucket, origin)`` partition it spans, so bigger
    tables cost fewer files and fewer Delta commits.

    Args:
        tables: Stream of packed statement tables.

    Returns:
        Number of rows written.
    """
    total = 0
    for table in tables:
        if not table.num_rows:
            continue
        self._statements.append(table)
        total += table.num_rows
    return total

writer(origin=None, role=None)

Get a bulk writer for adding entities/statements.

The writer owns its own lifecycle (insert the tail on success, drop the un-inserted buffer on error, close either way – see BaseJournalWriter.__exit__); this adds the freshness tag, stamped only when the block leaves cleanly.

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

Parameters:

Name Type Description Default
origin str | None

Origin tag for statements written through this writer.

None
role str | None

Default role – who is asserting these statements – for statements that carry none of their own.

None

Yields:

Type Description
BaseJournalWriter

The journal writer, open for the duration of the block.

Source code in ftm_lakehouse/repository/entities/main.py
@contextmanager
def writer(
    self, origin: str | None = None, role: str | None = None
) -> Generator[BaseJournalWriter, None, None]:
    """Get a bulk writer for adding entities/statements.

    The writer owns its own lifecycle (insert the tail on success, drop
    the un-inserted buffer on error, close either way – see
    `BaseJournalWriter.__exit__`); this adds the freshness tag,
    stamped only when the block leaves cleanly.

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

    Args:
        origin: Origin tag for statements written through this writer.
        role: Default role – who is asserting these statements – for
            statements that carry none of their own.

    Yields:
        The journal writer, open for the duration of the block.
    """
    with (
        self._tags.touch(tag.JOURNAL_UPDATED),
        self._journal.writer(origin, role) as writer,
    ):
        yield writer

JobRepository

Job tracking and status. Job runs are stored per job class – resolve the repository through the factory:

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

Document metadata assembled from archived files and their entities.

ftm_lakehouse.repository.DocumentRepository

Bases: DatasetHandle

Repository for documents to consume for clients.

This gathers File entities created during storing blobs 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 owns the query side – the folder paths the rows resolve against (make_paths), the ad-hoc lookups and the tombstoned ids.

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 File entities created during storing blobs 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 owns the query side – the folder paths the rows resolve
    against ([`make_paths`][DocumentRepository.make_paths]), the ad-hoc
    lookups and the tombstoned ids.

    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 _statements(self) -> ParquetStore:
        return ParquetStore(
            self.uri, self.dataset, self._model.shards, self._model.compression
        )

    @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()

    def make_paths(self) -> dict[str, str]:
        """Compute folder structure from Folder (parent) entities.

        Returns:
            Mapping of folder ID to complete path (e.g. "root/sub/folder")
        """
        # First pass: collect caption and parent for each folder
        folders: dict[str, tuple[str, str | None]] = {}
        for d in self._statements._query_data(
            Query(M(schemata="Folder")).select(P("parent"), *CAPTION_PROPS)
        ):
            data = d.to_dict()
            parents = data.get("properties", {}).get("parent", [])
            folders[data["id"]] = (
                get_filename(data),
                parents[0] if parents else None,
            )

        # Second pass: resolve full paths by walking up parent chain
        paths: dict[str, str] = {}
        for folder_id in folders:
            parts: list[str] = []
            current_id: str | None = folder_id
            seen: set[str] = set()
            while current_id and current_id in folders:
                if current_id in seen:
                    break  # cycle detection
                seen.add(current_id)
                caption, parent_id = folders[current_id]
                parts.append(caption)
                current_id = parent_id
            paths[folder_id] = "/".join(reversed(parts))

        return paths

    def iterate(self, q: Query | None = None) -> Documents:
        """Query the store for documents and build their csv rows.

        The ad-hoc entry point – the export sweep does not use it, since it
        already holds every entity and calls
        [`make_documents`][ftm_lakehouse.repository.artifacts.DocumentsArtifact.make_documents]
        directly against one `make_paths` result.
        """
        paths = self.make_paths()
        public_prefix = self._model.get_public_prefix()
        q = (q or Query()).where(*Q_DOCUMENTS).select(*SELECT)
        for d in self._statements._query_data(q):
            yield from self._artifact.make_documents(d.to_dict(), paths, public_prefix)

    def deleted_ids(self, since: datetime, origin: str | None = None) -> Iterator[str]:
        """Document ids with statements tombstoned since the given timestamp.

        Reads `ParquetStore.source_raw`, since the live view hides exactly
        the rows this asks about.
        """
        q = Query(*Q_DOCUMENTS, C(deleted_at__gte=since))
        if origin:
            q = q.where(C(origin=origin))
        return self._statements.get_entity_ids(q, source=self._statements.source_raw)

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

deleted_ids(since, origin=None)

Document ids with statements tombstoned since the given timestamp.

Reads ParquetStore.source_raw, since the live view hides exactly the rows this asks about.

Source code in ftm_lakehouse/repository/documents.py
def deleted_ids(self, since: datetime, origin: str | None = None) -> Iterator[str]:
    """Document ids with statements tombstoned since the given timestamp.

    Reads `ParquetStore.source_raw`, since the live view hides exactly
    the rows this asks about.
    """
    q = Query(*Q_DOCUMENTS, C(deleted_at__gte=since))
    if origin:
        q = q.where(C(origin=origin))
    return self._statements.get_entity_ids(q, source=self._statements.source_raw)

iterate(q=None)

Query the store for documents and build their csv rows.

The ad-hoc entry point – the export sweep does not use it, since it already holds every entity and calls make_documents directly against one make_paths result.

Source code in ftm_lakehouse/repository/documents.py
def iterate(self, q: Query | None = None) -> Documents:
    """Query the store for documents and build their csv rows.

    The ad-hoc entry point – the export sweep does not use it, since it
    already holds every entity and calls
    [`make_documents`][ftm_lakehouse.repository.artifacts.DocumentsArtifact.make_documents]
    directly against one `make_paths` result.
    """
    paths = self.make_paths()
    public_prefix = self._model.get_public_prefix()
    q = (q or Query()).where(*Q_DOCUMENTS).select(*SELECT)
    for d in self._statements._query_data(q):
        yield from self._artifact.make_documents(d.to_dict(), paths, public_prefix)

make_paths()

Compute folder structure from Folder (parent) entities.

Returns:

Type Description
dict[str, str]

Mapping of folder ID to complete path (e.g. "root/sub/folder")

Source code in ftm_lakehouse/repository/documents.py
def make_paths(self) -> dict[str, str]:
    """Compute folder structure from Folder (parent) entities.

    Returns:
        Mapping of folder ID to complete path (e.g. "root/sub/folder")
    """
    # First pass: collect caption and parent for each folder
    folders: dict[str, tuple[str, str | None]] = {}
    for d in self._statements._query_data(
        Query(M(schemata="Folder")).select(P("parent"), *CAPTION_PROPS)
    ):
        data = d.to_dict()
        parents = data.get("properties", {}).get("parent", [])
        folders[data["id"]] = (
            get_filename(data),
            parents[0] if parents else None,
        )

    # Second pass: resolve full paths by walking up parent chain
    paths: dict[str, str] = {}
    for folder_id in folders:
        parts: list[str] = []
        current_id: str | None = folder_id
        seen: set[str] = set()
        while current_id and current_id in folders:
            if current_id in seen:
                break  # cycle detection
            seen.add(current_id)
            caption, parent_id = folders[current_id]
            parts.append(caption)
            current_id = parent_id
        paths[folder_id] = "/".join(reversed(parts))

    return paths

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 one dataset produces – statements.csv, entities.ftm.json, documents.csv (one per origin scope), statistics.json, index.json – and the diff series that ride alongside the streamed ones.

from ftm_lakehouse.repository import get_artifacts

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

ftm_lakehouse.repository.ArtifactsRepository

Bases: DatasetHandle

The export artifacts one dataset produces.

Binds the declarations above to this dataset, so a caller addresses an artifact by kind and gets something that knows where it lives, whether it is current, how to write it and how to describe itself in index.json.

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

    Binds the declarations above to this dataset, so a caller addresses an
    artifact by kind and gets something that knows where it lives, whether it
    is current, how to write it and how to describe itself in ``index.json``.

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

    def __getitem__(self, kind: ExportKind | str) -> Artifact:
        """The artifact answering to one export kind, bound to this dataset."""
        return ARTIFACTS_BY_KIND[ExportKind(kind)](self)

    def __iter__(self) -> Iterator[Artifact]:
        yield from (a(self) for a in ARTIFACTS)

    @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 statistics(self) -> StatisticsArtifact:
        return StatisticsArtifact(self)

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

    def document_scopes(self) -> Iterator[DocumentsArtifact]:
        """The documents variants a full export writes, one per origin."""
        yield from (self.documents[origin] for origin in DOCUMENT_ORIGINS)

    def written_by(self, kinds: Iterable[ExportKind]) -> Iterator[Artifact]:
        """Every artifact those kinds cover, origin scopes expanded."""
        for kind in kinds:
            if kind == ExportKind.documents:
                yield from self.document_scopes()
            else:
                yield self[kind]

    def session(
        self,
        now: datetime,
        kinds: Iterable[ExportKind],
        version: int | None,
        make_diff: bool = True,
    ) -> ExportSession:
        """The artifacts an export run covers, ready to be driven as one loop.

        Args:
            now: Timestamp the run started – diff files are named after it and
                diff states are recorded at it.
            kinds: Which exports this run covers.
            version: Current delta table version, which the diff series
                resolve their window against.
            make_diff: Whether diff series run at all.
        """
        runs = tuple(a.run(now) for a in self.written_by(kinds))
        return ExportSession(runs, version, make_diff)

    def resources(self) -> Iterator[DataResource]:
        """Describe every written artifact for ``index.json``.

        ``index.json`` itself is left out – it is the file being written.
        """
        public_prefix = self._model.get_public_prefix()
        if not public_prefix:
            return
        for artifact in (
            self.statements,
            self.entities,
            *self.document_scopes(),
            self.statistics,
        ):
            resource = artifact.make_resource(public_prefix)
            if resource is not None:
                yield resource

__getitem__(kind)

The artifact answering to one export kind, bound to this dataset.

Source code in ftm_lakehouse/repository/artifacts.py
def __getitem__(self, kind: ExportKind | str) -> Artifact:
    """The artifact answering to one export kind, bound to this dataset."""
    return ARTIFACTS_BY_KIND[ExportKind(kind)](self)

document_scopes()

The documents variants a full export writes, one per origin.

Source code in ftm_lakehouse/repository/artifacts.py
def document_scopes(self) -> Iterator[DocumentsArtifact]:
    """The documents variants a full export writes, one per origin."""
    yield from (self.documents[origin] for origin in DOCUMENT_ORIGINS)

resources()

Describe every written artifact for index.json.

index.json itself is left out – it is the file being written.

Source code in ftm_lakehouse/repository/artifacts.py
def resources(self) -> Iterator[DataResource]:
    """Describe every written artifact for ``index.json``.

    ``index.json`` itself is left out – it is the file being written.
    """
    public_prefix = self._model.get_public_prefix()
    if not public_prefix:
        return
    for artifact in (
        self.statements,
        self.entities,
        *self.document_scopes(),
        self.statistics,
    ):
        resource = artifact.make_resource(public_prefix)
        if resource is not None:
            yield resource

session(now, kinds, version, make_diff=True)

The artifacts an export run covers, ready to be driven as one loop.

Parameters:

Name Type Description Default
now datetime

Timestamp the run started – diff files are named after it and diff states are recorded at it.

required
kinds Iterable[ExportKind]

Which exports this run covers.

required
version int | None

Current delta table version, which the diff series resolve their window against.

required
make_diff bool

Whether diff series run at all.

True
Source code in ftm_lakehouse/repository/artifacts.py
def session(
    self,
    now: datetime,
    kinds: Iterable[ExportKind],
    version: int | None,
    make_diff: bool = True,
) -> ExportSession:
    """The artifacts an export run covers, ready to be driven as one loop.

    Args:
        now: Timestamp the run started – diff files are named after it and
            diff states are recorded at it.
        kinds: Which exports this run covers.
        version: Current delta table version, which the diff series
            resolve their window against.
        make_diff: Whether diff series run at all.
    """
    runs = tuple(a.run(now) for a in self.written_by(kinds))
    return ExportSession(runs, version, make_diff)

written_by(kinds)

Every artifact those kinds cover, origin scopes expanded.

Source code in ftm_lakehouse/repository/artifacts.py
def written_by(self, kinds: Iterable[ExportKind]) -> Iterator[Artifact]:
    """Every artifact those kinds cover, origin scopes expanded."""
    for kind in kinds:
        if kind == ExportKind.documents:
            yield from self.document_scopes()
        else:
            yield self[kind]

An artifact is a stateless declaration bound to a dataset; everything true only while an export runs – the open writer, the diff window, the counts – lives on its run, driven by ExportSession as one loop.

ftm_lakehouse.repository.artifacts.Artifact

One export artifact, bound to a dataset.

Stateless – see the module docstring. Per-run state lives on ArtifactRun.

Parameters:

Name Type Description Default
dataset DatasetHandle

The dataset handle the artifact belongs to.

required
origin str | None

Source tag this variant is scoped to, None for the unscoped one.

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

    Stateless – see the module docstring. Per-run state lives on `ArtifactRun`.

    Args:
        dataset: The dataset handle the artifact belongs to.
        origin: Source tag this variant is scoped to, ``None`` for the
            unscoped one.
    """

    base: ClassVar[StoreKey]
    kind: ClassVar[ExportKind]
    mime_type: ClassVar[str] = CSV
    dependencies: ClassVar[tuple[str | StoreKey, ...]] = (tag.STATEMENTS_OPTIMIZED,)
    compressed: ClassVar[bool] = True
    fieldnames: ClassVar[list[str] | None] = None

    def __init__(self, dataset: DatasetHandle, origin: str | None = None) -> None:
        self.dataset = dataset
        # an origin scope names a file *and* reaches SQL as a filter, so it is
        # validated where it enters, like every other origin in the codebase
        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 is_fresh(self) -> bool:
        """Whether the artifact exists *and* is newer than its dependencies.

        A missing artifact is never fresh – there is nothing to be current.
        """
        if not self.exists():
            return False
        return self.dataset._tags.is_latest(self.tag, self.dependencies)

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

    def writer(self, lazy: bool = False) -> Writer:
        """A writer for this artifact, in its own format and codec.

        Args:
            lazy: Defer creating the file to the first row. An artifact is a
                whole picture of the store, so it opens eagerly – an empty
                sweep must truncate a stale one rather than leave it. A diff
                is the opposite: no changes means no file.
        """
        return Writer(
            self.dataset._store.to_uri(self.key),
            output_format=self.format,
            compression=self.compression,
            fieldnames=self.fieldnames,
            lazy=lazy,
        )

    @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) -> "ArtifactRun":
        """The per-run object that writes this artifact."""
        return ArtifactRun(self, now)

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)

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)

is_fresh()

Whether the artifact exists and is newer than its dependencies.

A missing artifact is never fresh – there is nothing to be current.

Source code in ftm_lakehouse/repository/artifacts.py
def is_fresh(self) -> bool:
    """Whether the artifact exists *and* is newer than its dependencies.

    A missing artifact is never fresh – there is nothing to be current.
    """
    if not self.exists():
        return False
    return self.dataset._tags.is_latest(self.tag, self.dependencies)

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,
    )

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)

The per-run object that writes this artifact.

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

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(lazy=False)

A writer for this artifact, in its own format and codec.

Parameters:

Name Type Description Default
lazy bool

Defer creating the file to the first row. An artifact is a whole picture of the store, so it opens eagerly – an empty sweep must truncate a stale one rather than leave it. A diff is the opposite: no changes means no file.

False
Source code in ftm_lakehouse/repository/artifacts.py
def writer(self, lazy: bool = False) -> Writer:
    """A writer for this artifact, in its own format and codec.

    Args:
        lazy: Defer creating the file to the first row. An artifact is a
            whole picture of the store, so it opens eagerly – an empty
            sweep must truncate a stale one rather than leave it. A diff
            is the opposite: no changes means no file.
    """
    return Writer(
        self.dataset._store.to_uri(self.key),
        output_format=self.format,
        compression=self.compression,
        fieldnames=self.fieldnames,
        lazy=lazy,
    )

ftm_lakehouse.repository.artifacts.DiffableArtifact

Bases: Artifact

An artifact that keeps a diff series alongside it.

Owns the series' paths and its stored {timestamp}:{version} state. Whether this run writes a diff, and against which window, belongs to DiffableRun.

The series directory carries neither extension nor codec – it doubles as the series' freshness tag and diff-state key, so it must not move when a dataset changes its compression.

Source code in ftm_lakehouse/repository/artifacts.py
class DiffableArtifact(Artifact):
    """An artifact that keeps a diff series alongside it.

    Owns the series' paths and its stored ``{timestamp}:{version}`` state.
    Whether *this* run writes a diff, and against which window, belongs to
    `DiffableRun`.

    The series directory carries neither extension nor codec – it doubles as
    the series' freshness tag and diff-state key, so it must not move when a
    dataset changes its compression.
    """

    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_writer(self, ts: datetime) -> Writer:
        """A writer for one diff file in this series."""
        fieldnames = ["op", *self.fieldnames] if self.fieldnames else None
        return Writer(
            self.dataset._store.to_uri(self.series(ts) + self.compression),
            output_format=self.format,
            compression=self.compression,
            fieldnames=fieldnames,
            # a series with no changes in the window leaves no file at all
            lazy=True,
        )

series property

The directory this variant's diff files live in.

state_key property

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

diff_writer(ts)

A writer for one diff file in this series.

Source code in ftm_lakehouse/repository/artifacts.py
def diff_writer(self, ts: datetime) -> Writer:
    """A writer for one diff file in this series."""
    fieldnames = ["op", *self.fieldnames] if self.fieldnames else None
    return Writer(
        self.dataset._store.to_uri(self.series(ts) + self.compression),
        output_format=self.format,
        compression=self.compression,
        fieldnames=fieldnames,
        # a series with no changes in the window leaves no file at all
        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 delta series – origin-scopable.

Owns the write side of the documents export: which entities belong in it, and what rows each contributes. The query side – the folder map, the ad-hoc lookups, the tombstoned ids – stays on DocumentRepository.

Source code in ftm_lakehouse/repository/artifacts.py
class DocumentsArtifact(DiffableArtifact):
    """Document metadata, and its delta series – origin-scopable.

    Owns the *write* side of the documents export: which entities belong in
    it, and what rows each contributes. The query side – the folder map, the
    ad-hoc lookups, the tombstoned ids – stays on
    [`DocumentRepository`][ftm_lakehouse.repository.documents.DocumentRepository].
    """

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

    @staticmethod
    def is_document(data: SDict) -> bool:
        """Whether an entity dict belongs in the documents export.

        The in-Python spelling of ``Q_DOCUMENTS``: a ``Document`` descendant
        that is not a bare ``Folder`` (those are the path scaffolding, not
        files) and actually has a content hash to point at.
        """
        schema = data.get("schema")
        if not schema:
            return False
        schema_ = model.get(str(schema))
        if schema_ is None or not schema_.is_a("Document"):
            return False
        if schema_.name == "Folder":
            return False
        return bool(data.get("properties", {}).get("contentHash"))

    def make_documents(
        self,
        data: SDict,
        paths: dict[str, str],
        public_prefix: str | None = None,
    ) -> Documents:
        """The csv rows one entity dict contributes.

        One row per resolvable parent folder, so a file living in two places is
        listed under both; a file with no resolvable parent still gets its one
        unpathed row. Each row is its own object, so a caller may materialise
        them – the diff writes the same rows the csv did.

        Args:
            data: Entity dict, as `EntityPayload.to_dict` returns.
            paths: Folder id to path map from `DocumentRepository.make_paths`.
            public_prefix: Public url prefix to build blob links against.

        Yields:
            One `Document` per resolvable parent, else a single unpathed one.
        """
        document = Document.from_entity_dict(data)
        if public_prefix:
            document.public_url = join_uri(
                public_prefix, path.ArchiveKey(document.checksum).blob
            )
        paths_ = [p for p in data.get("properties", {}).get("parent", []) if p in paths]
        if not paths_:
            yield document
            return
        # a copy per parent: the same file in two folders is two rows, and a
        # caller that materialises them must not get two views of one object
        for parent in paths_:
            yield document.model_copy(update={"path": paths[parent]})

    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) -> "DocumentsRun":
        return DocumentsRun(self, now)

is_document(data) staticmethod

Whether an entity dict belongs in the documents export.

The in-Python spelling of Q_DOCUMENTS: a Document descendant that is not a bare Folder (those are the path scaffolding, not files) and actually has a content hash to point at.

Source code in ftm_lakehouse/repository/artifacts.py
@staticmethod
def is_document(data: SDict) -> bool:
    """Whether an entity dict belongs in the documents export.

    The in-Python spelling of ``Q_DOCUMENTS``: a ``Document`` descendant
    that is not a bare ``Folder`` (those are the path scaffolding, not
    files) and actually has a content hash to point at.
    """
    schema = data.get("schema")
    if not schema:
        return False
    schema_ = model.get(str(schema))
    if schema_ is None or not schema_.is_a("Document"):
        return False
    if schema_.name == "Folder":
        return False
    return bool(data.get("properties", {}).get("contentHash"))

make_documents(data, paths, public_prefix=None)

The csv rows one entity dict contributes.

One row per resolvable parent folder, so a file living in two places is listed under both; a file with no resolvable parent still gets its one unpathed row. Each row is its own object, so a caller may materialise them – the diff writes the same rows the csv did.

Parameters:

Name Type Description Default
data SDict

Entity dict, as EntityPayload.to_dict returns.

required
paths dict[str, str]

Folder id to path map from DocumentRepository.make_paths.

required
public_prefix str | None

Public url prefix to build blob links against.

None

Yields:

Type Description
Documents

One Document per resolvable parent, else a single unpathed one.

Source code in ftm_lakehouse/repository/artifacts.py
def make_documents(
    self,
    data: SDict,
    paths: dict[str, str],
    public_prefix: str | None = None,
) -> Documents:
    """The csv rows one entity dict contributes.

    One row per resolvable parent folder, so a file living in two places is
    listed under both; a file with no resolvable parent still gets its one
    unpathed row. Each row is its own object, so a caller may materialise
    them – the diff writes the same rows the csv did.

    Args:
        data: Entity dict, as `EntityPayload.to_dict` returns.
        paths: Folder id to path map from `DocumentRepository.make_paths`.
        public_prefix: Public url prefix to build blob links against.

    Yields:
        One `Document` per resolvable parent, else a single unpathed one.
    """
    document = Document.from_entity_dict(data)
    if public_prefix:
        document.public_url = join_uri(
            public_prefix, path.ArchiveKey(document.checksum).blob
        )
    paths_ = [p for p in data.get("properties", {}).get("parent", []) if p in paths]
    if not paths_:
        yield document
        return
    # a copy per parent: the same file in two folders is two rows, and a
    # caller that materialises them must not get two views of one object
    for parent in paths_:
        yield document.model_copy(update={"path": paths[parent]})

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