lacing 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lacing/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """lacing — interval annotation system.
2
+
3
+ Standoff, interval-keyed annotations with rational time, ELAN tier
4
+ stereotypes, Allen's interval algebra, and a ``MutableMapping`` facade.
5
+
6
+ Quick start:
7
+
8
+ >>> from lacing import RationalTime, TimeInterval, Annotation, MemoryStore
9
+ >>> # Load a TextGrid, query overlaps, save as WebVTT — see misc/docs/.
10
+
11
+ Read ``CLAUDE.md`` and ``misc/docs/Lacing Development Roadmap.md`` for the
12
+ full story. ``.claude/skills/`` contains the rules.
13
+ """
14
+
15
+ from lacing.allen import AllenRelation
16
+ from lacing.quality import (
17
+ boundary_iou,
18
+ cohen_kappa,
19
+ interval_iou,
20
+ krippendorff_alpha,
21
+ )
22
+ from lacing.model import (
23
+ Annotation,
24
+ AnnotationRef,
25
+ MediaRef,
26
+ NodeRef,
27
+ Provenance,
28
+ Reference,
29
+ )
30
+ from lacing.store import (
31
+ IntervalAnnotationStore,
32
+ MemoryStore,
33
+ SchemaMismatchError,
34
+ SqliteStore,
35
+ )
36
+ from lacing.tier import Tier, TierStereotype
37
+ from lacing.time import (
38
+ DEFAULT_RATE,
39
+ LossyTimeConversionError,
40
+ RationalTime,
41
+ TimeInterval,
42
+ )
43
+
44
+ __all__ = [
45
+ # time
46
+ "RationalTime",
47
+ "TimeInterval",
48
+ "DEFAULT_RATE",
49
+ "LossyTimeConversionError",
50
+ # tier
51
+ "Tier",
52
+ "TierStereotype",
53
+ # model
54
+ "Annotation",
55
+ "Reference",
56
+ "MediaRef",
57
+ "NodeRef",
58
+ "AnnotationRef",
59
+ "Provenance",
60
+ # allen
61
+ "AllenRelation",
62
+ # store
63
+ "IntervalAnnotationStore",
64
+ "MemoryStore",
65
+ "SqliteStore",
66
+ "SchemaMismatchError",
67
+ # quality
68
+ "cohen_kappa",
69
+ "krippendorff_alpha",
70
+ "interval_iou",
71
+ "boundary_iou",
72
+ ]
@@ -0,0 +1,133 @@
1
+ """I/O adapter registry.
2
+
3
+ The core never imports a format module. Each adapter registers itself by
4
+ calling :func:`register_adapter` at import time. Users opt in by importing
5
+ the adapter module:
6
+
7
+ from lacing.adapters import textgrid # noqa: F401 — registers itself
8
+
9
+ Or by using the convenience top-level loaders that look up by extension or
10
+ ``media_type``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from lacing.store import IntervalAnnotationStore
21
+
22
+
23
+ LoadFn = Callable[..., IntervalAnnotationStore]
24
+ DumpFn = Callable[..., bytes | None]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AdapterSpec:
29
+ """Registered adapter for one format."""
30
+
31
+ name: str
32
+ extensions: tuple[str, ...]
33
+ media_types: tuple[str, ...]
34
+ load: LoadFn
35
+ dump: DumpFn
36
+ body_schema_uris: tuple[str, ...] = field(default_factory=tuple)
37
+ description: str = ""
38
+
39
+
40
+ _REGISTRY: dict[str, AdapterSpec] = {}
41
+
42
+
43
+ def register_adapter(
44
+ *,
45
+ name: str,
46
+ load: LoadFn,
47
+ dump: DumpFn,
48
+ extensions: tuple[str, ...] = (),
49
+ media_types: tuple[str, ...] = (),
50
+ body_schema_uris: tuple[str, ...] = (),
51
+ description: str = "",
52
+ ) -> AdapterSpec:
53
+ """Register an adapter. Idempotent: re-registering the same name replaces."""
54
+ spec = AdapterSpec(
55
+ name=name,
56
+ extensions=tuple(e.lower() for e in extensions),
57
+ media_types=tuple(media_types),
58
+ load=load,
59
+ dump=dump,
60
+ body_schema_uris=tuple(body_schema_uris),
61
+ description=description,
62
+ )
63
+ _REGISTRY[name] = spec
64
+ return spec
65
+
66
+
67
+ def get_adapter(name: str) -> AdapterSpec:
68
+ """Look up an adapter by name. Raises ``KeyError`` if missing."""
69
+ return _REGISTRY[name]
70
+
71
+
72
+ def find_adapter(
73
+ *, extension: str | None = None, media_type: str | None = None
74
+ ) -> AdapterSpec | None:
75
+ """Find an adapter by extension (case-insensitive) or media type."""
76
+ if extension is not None:
77
+ ext = extension.lower()
78
+ if not ext.startswith("."):
79
+ ext = "." + ext
80
+ for spec in _REGISTRY.values():
81
+ if ext in spec.extensions:
82
+ return spec
83
+ if media_type is not None:
84
+ for spec in _REGISTRY.values():
85
+ if media_type in spec.media_types:
86
+ return spec
87
+ return None
88
+
89
+
90
+ def registered() -> list[AdapterSpec]:
91
+ """All currently registered adapters (in registration order)."""
92
+ return list(_REGISTRY.values())
93
+
94
+
95
+ def load(
96
+ source: str | bytes | os.PathLike,
97
+ *,
98
+ format: str | None = None,
99
+ **kwargs: Any,
100
+ ) -> IntervalAnnotationStore:
101
+ """Convenience: dispatch ``source`` to the right adapter.
102
+
103
+ If ``format`` is given, looks up by name. Otherwise, if ``source`` is a
104
+ path, infers from extension. Raises ``ValueError`` if it can't pick.
105
+ """
106
+ spec = _resolve_adapter(source, format)
107
+ return spec.load(source, **kwargs)
108
+
109
+
110
+ def dump(
111
+ store: IntervalAnnotationStore,
112
+ target: str | os.PathLike | None = None,
113
+ *,
114
+ format: str,
115
+ **kwargs: Any,
116
+ ) -> bytes | None:
117
+ """Convenience: serialize ``store`` via the named adapter."""
118
+ spec = get_adapter(format)
119
+ return spec.dump(store, target, **kwargs)
120
+
121
+
122
+ def _resolve_adapter(source: Any, format: str | None) -> AdapterSpec:
123
+ if format is not None:
124
+ return get_adapter(format)
125
+ if isinstance(source, (str, os.PathLike)) and not isinstance(source, bytes):
126
+ ext = os.path.splitext(os.fspath(source))[1]
127
+ if ext:
128
+ spec = find_adapter(extension=ext)
129
+ if spec is not None:
130
+ return spec
131
+ raise ValueError(
132
+ "Cannot infer adapter: pass `format=<name>` or use a recognized extension."
133
+ )
@@ -0,0 +1,175 @@
1
+ """``.annot`` portable file format adapter.
2
+
3
+ The ``.annot`` file is a SQLite database with the schema defined in
4
+ ``lacing.store.sqlite``. It's the recommended portable handoff and archive
5
+ format (BACK-DOC §3.1: "SQLite-as-app-format" — Git-trackable,
6
+ email-attachable, single-file).
7
+
8
+ Unlike text-based adapters (TextGrid, WebVTT, JSON-LD), this one is
9
+ non-lossy: the full annotation envelope, references, body, body schema URI,
10
+ provenance, and confidence all round-trip exactly.
11
+
12
+ When loading, returns an in-memory ``MemoryStore`` for compatibility with
13
+ the rest of the adapter API. To open an ``.annot`` file as a *persistent*
14
+ store you can mutate, use ``SqliteStore(path)`` directly:
15
+
16
+ >>> from lacing.store import SqliteStore
17
+ >>> store = SqliteStore("project.annot") # writes go straight to disk
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from lacing.adapters import register_adapter
29
+ from lacing.store import IntervalAnnotationStore, MemoryStore, SqliteStore
30
+ from lacing.store.sqlite import from_memory, to_memory
31
+
32
+
33
+ ADAPTER_NAME = "annot"
34
+ DEFAULT_BODY_SCHEMA_URIS = () # the .annot format preserves whatever URIs are in the data
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # load
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def load(
43
+ source: str | bytes | os.PathLike,
44
+ *,
45
+ persistent: bool = False,
46
+ **_kwargs: Any,
47
+ ) -> IntervalAnnotationStore:
48
+ """Open an ``.annot`` file.
49
+
50
+ Args:
51
+ source: Path to an ``.annot`` file. Bytes input is supported by
52
+ writing to a temp file first.
53
+ persistent: If True, return an open ``SqliteStore`` (writes go to
54
+ the file). If False (default), return a ``MemoryStore`` snapshot.
55
+
56
+ Returns:
57
+ ``MemoryStore`` (default) or ``SqliteStore`` (if ``persistent=True``).
58
+ """
59
+ if isinstance(source, (bytes, bytearray)):
60
+ with tempfile.NamedTemporaryFile(suffix=".annot", delete=False) as f:
61
+ f.write(source)
62
+ tmp_path = f.name
63
+ try:
64
+ sqlite_store = SqliteStore(tmp_path)
65
+ mem = to_memory(sqlite_store)
66
+ sqlite_store.close()
67
+ return mem
68
+ finally:
69
+ if not persistent:
70
+ os.unlink(tmp_path)
71
+
72
+ path = os.fspath(source)
73
+ if persistent:
74
+ return SqliteStore(path)
75
+
76
+ sqlite_store = SqliteStore(path)
77
+ try:
78
+ return to_memory(sqlite_store)
79
+ finally:
80
+ sqlite_store.close()
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # dump
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def dump(
89
+ store: IntervalAnnotationStore,
90
+ target: str | os.PathLike | None = None,
91
+ *,
92
+ overwrite: bool = True,
93
+ **_kwargs: Any,
94
+ ) -> bytes | None:
95
+ """Write ``store`` as an ``.annot`` SQLite file.
96
+
97
+ Args:
98
+ store: Source store. Can be any IntervalAnnotationStore.
99
+ target: Output path. None = return bytes.
100
+ overwrite: If True (default), replace any existing file at ``target``.
101
+ If False and the file exists, raise ``FileExistsError``.
102
+ """
103
+ if target is None:
104
+ with tempfile.NamedTemporaryFile(suffix=".annot", delete=False) as f:
105
+ tmp_path = f.name
106
+ try:
107
+ sqlite_store = _build_at(store, tmp_path)
108
+ sqlite_store.close()
109
+ return Path(tmp_path).read_bytes()
110
+ finally:
111
+ os.unlink(tmp_path)
112
+
113
+ target_path = Path(os.fspath(target))
114
+ if target_path.exists():
115
+ if not overwrite:
116
+ raise FileExistsError(target_path)
117
+ target_path.unlink()
118
+ sqlite_store = _build_at(store, target_path)
119
+ sqlite_store.close()
120
+ return None
121
+
122
+
123
+ def _build_at(store: IntervalAnnotationStore, path) -> SqliteStore:
124
+ """Materialize ``store`` as a fresh SqliteStore at ``path``.
125
+
126
+ If ``store`` is already a ``SqliteStore``, copy the underlying file
127
+ instead of re-inserting row by row — same content, faster and bit-exact.
128
+ """
129
+ if isinstance(store, SqliteStore):
130
+ # Copy the file to the target location.
131
+ src = os.fspath(store.path)
132
+ if src == ":memory:":
133
+ # In-memory source — fall through to row-by-row.
134
+ return from_memory(_to_memory_like(store), path)
135
+ shutil.copyfile(src, os.fspath(path))
136
+ return SqliteStore(path)
137
+ return from_memory(_to_memory_like(store), path)
138
+
139
+
140
+ def _to_memory_like(store: IntervalAnnotationStore):
141
+ """Coerce any store to something with ``.tiers()`` and ``.all()``.
142
+
143
+ ``MemoryStore`` and ``SqliteStore`` both already have these. This is a
144
+ forward-compatibility shim for backends that don't expose ``all`` directly.
145
+ """
146
+ if hasattr(store, "all") and hasattr(store, "tiers"):
147
+ return store
148
+ # Fallback: build a transient MemoryStore.
149
+ mem = MemoryStore()
150
+ if hasattr(store, "tiers"):
151
+ for t in store.tiers(): # type: ignore[attr-defined]
152
+ mem.add_tier(t)
153
+ for key in store: # type: ignore[attr-defined]
154
+ for ann in store[key]: # type: ignore[index]
155
+ mem.add(ann)
156
+ return mem
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # registration
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ register_adapter(
165
+ name=ADAPTER_NAME,
166
+ load=load,
167
+ dump=dump,
168
+ extensions=(".annot",),
169
+ media_types=("application/x-lacing-annot",),
170
+ body_schema_uris=DEFAULT_BODY_SCHEMA_URIS,
171
+ description=(
172
+ "Lacing's portable .annot SQLite file format. Lossless round-trip; "
173
+ "preserves the full annotation envelope, provenance, and tier hierarchy."
174
+ ),
175
+ )