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 +72 -0
- lacing/adapters/__init__.py +133 -0
- lacing/adapters/annot.py +175 -0
- lacing/adapters/eaf.py +428 -0
- lacing/adapters/textgrid.py +336 -0
- lacing/adapters/web_annotation.py +429 -0
- lacing/adapters/webvtt.py +312 -0
- lacing/allen.py +220 -0
- lacing/cli.py +253 -0
- lacing/model.py +131 -0
- lacing/quality.py +239 -0
- lacing/store/__init__.py +38 -0
- lacing/store/base.py +131 -0
- lacing/store/memory.py +221 -0
- lacing/store/postgres.py +754 -0
- lacing/store/sqlite.py +710 -0
- lacing/tier.py +227 -0
- lacing/time.py +294 -0
- lacing-0.0.2.dist-info/METADATA +240 -0
- lacing-0.0.2.dist-info/RECORD +23 -0
- lacing-0.0.2.dist-info/WHEEL +4 -0
- lacing-0.0.2.dist-info/entry_points.txt +2 -0
- lacing-0.0.2.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
lacing/adapters/annot.py
ADDED
|
@@ -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
|
+
)
|