matchlab 0.1.0.dev1__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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Resolver collapses model edges into clusters and materialises the resolver output."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, Self
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from matchlab.adapters import Adapter, Fingerprint
|
|
8
|
+
from matchlab.core.dataframes import qualify
|
|
9
|
+
from matchlab.core.exceptions import StepNotFound
|
|
10
|
+
from matchlab.core.kinds import StepKind
|
|
11
|
+
from matchlab.core.resolver_output import materialise_resolver_output
|
|
12
|
+
from matchlab.models import Model
|
|
13
|
+
from matchlab.recordstep import IdentifierRead, RecordStep, build_record_step
|
|
14
|
+
from matchlab.resolvers.base import ResolverMethod, ResolverSettings
|
|
15
|
+
from matchlab.resolvers.components import Components
|
|
16
|
+
from matchlab.sources import Source
|
|
17
|
+
from matchlab.specs import ResolverSpec
|
|
18
|
+
|
|
19
|
+
_RESOLVER_CLASSES: dict[str, type[ResolverMethod]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_resolver_class(resolver_class: type[ResolverMethod]) -> None:
|
|
23
|
+
"""Register a resolver methodology so it can be named in a plan."""
|
|
24
|
+
if not issubclass(resolver_class, ResolverMethod):
|
|
25
|
+
raise ValueError("The argument is not a subclass of ResolverMethod.")
|
|
26
|
+
_RESOLVER_CLASSES[resolver_class.__name__] = resolver_class
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
add_resolver_class(Components)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Resolver(RecordStep):
|
|
33
|
+
"""Clusters computed over one or more models' edges.
|
|
34
|
+
|
|
35
|
+
A resolver is also a `RecordStep`. Read as records, its rows are the sources it
|
|
36
|
+
covers, with `id` set to the entity root, so matching on top of a resolver is how a
|
|
37
|
+
plan layers (`deduped.link(dh, …)`). The resolver output it stores is a separate
|
|
38
|
+
artifact, and the record step itself is derived on read.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
kind: ClassVar[StepKind] = StepKind.RESOLVER
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*models: Model,
|
|
46
|
+
resolver_class: type[ResolverMethod] | str = "Components",
|
|
47
|
+
resolver_settings: ResolverSettings | dict[str, Any] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Define a resolver.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
*models: The models whose edges to resolve. At least one.
|
|
53
|
+
resolver_class: A `ResolverMethod` subclass or its registered name.
|
|
54
|
+
Defaults to connected components.
|
|
55
|
+
resolver_settings: Settings for that methodology.
|
|
56
|
+
"""
|
|
57
|
+
deduped: list[Model] = []
|
|
58
|
+
for model in models:
|
|
59
|
+
if not isinstance(model, Model):
|
|
60
|
+
raise ValueError(f"Resolver inputs must be models, got {type(model)}")
|
|
61
|
+
if not any(model is seen for seen in deduped):
|
|
62
|
+
deduped.append(model)
|
|
63
|
+
if not deduped:
|
|
64
|
+
raise ValueError("A resolver needs at least one model")
|
|
65
|
+
|
|
66
|
+
self.inputs = tuple(deduped)
|
|
67
|
+
|
|
68
|
+
self.resolver_class = (
|
|
69
|
+
_RESOLVER_CLASSES[resolver_class]
|
|
70
|
+
if isinstance(resolver_class, str)
|
|
71
|
+
else resolver_class
|
|
72
|
+
)
|
|
73
|
+
settings = resolver_settings if resolver_settings is not None else {}
|
|
74
|
+
if isinstance(settings, dict):
|
|
75
|
+
settings_class = self.resolver_class.model_fields["settings"].annotation
|
|
76
|
+
self.resolver_settings = settings_class(
|
|
77
|
+
**{
|
|
78
|
+
field: self._positions(field, value)
|
|
79
|
+
for field, value in settings.items()
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
self.resolver_settings = settings
|
|
84
|
+
self.resolver_instance = self.resolver_class(settings=self.resolver_settings)
|
|
85
|
+
|
|
86
|
+
super().__init__(upstream=self.inputs)
|
|
87
|
+
|
|
88
|
+
def _positions(self, field: str, value: Any) -> Any: # noqa: ANN401 - any setting
|
|
89
|
+
"""Replace `Model` keys in a setting with the position of that input.
|
|
90
|
+
|
|
91
|
+
A setting that points at one of this resolver's inputs, such as a per-model
|
|
92
|
+
threshold, is written as `{model: 0.9}`. That holds the object you already
|
|
93
|
+
have, rather than retyping its name. Here is the only place that can be
|
|
94
|
+
translated, because here is where both the models and the order they were
|
|
95
|
+
given in are known.
|
|
96
|
+
|
|
97
|
+
Positions rather than names, because a name is not identity. Renaming a model
|
|
98
|
+
would move this resolver's fingerprint without changing a byte of its output.
|
|
99
|
+
Reordering the inputs *does* reassign thresholds, but that is not inconsistent.
|
|
100
|
+
Reordering already changes the fingerprint anyway, since parents fold in in
|
|
101
|
+
order, making this a genuinely different resolver.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If a setting names a model that is not an input.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(value, dict):
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
position = {id(model): index for index, model in enumerate(self.inputs)}
|
|
110
|
+
translated: dict[Any, Any] = {}
|
|
111
|
+
for key, setting in value.items():
|
|
112
|
+
if not isinstance(key, Model):
|
|
113
|
+
translated[key] = setting
|
|
114
|
+
continue
|
|
115
|
+
if id(key) not in position:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"'{field}' has an entry for a model this resolver does not read. "
|
|
118
|
+
f"It resolves {len(self.inputs)} model(s), and a setting may only "
|
|
119
|
+
"point at one of those."
|
|
120
|
+
)
|
|
121
|
+
translated[position[id(key)]] = setting
|
|
122
|
+
return translated
|
|
123
|
+
|
|
124
|
+
# -- Step contract ----------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
"""A resolver is drawn with the methodology implementing it."""
|
|
128
|
+
return f"{self.kind}({self.resolver_class.__name__})"
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def spec(self) -> ResolverSpec:
|
|
132
|
+
"""The serialisable spec for this resolver."""
|
|
133
|
+
return ResolverSpec(
|
|
134
|
+
resolver_class=self.resolver_class.__name__,
|
|
135
|
+
resolver_settings=self.resolver_settings.model_dump(mode="json"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# -- publishing -------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def publish(self, label: str, overwrite: bool = False) -> Self:
|
|
141
|
+
"""Point a label at this resolver's output, so it can be found without the plan.
|
|
142
|
+
|
|
143
|
+
Publishing is an act, not a property of the plan. A label changes nothing
|
|
144
|
+
about what gets computed, and there is nothing to point at until the resolver
|
|
145
|
+
output exists. Publish after collection, for example
|
|
146
|
+
`resolver.collect().publish("x")`. A plan that is never published is still
|
|
147
|
+
perfectly runnable, just unlabelled.
|
|
148
|
+
|
|
149
|
+
A *label* rather than a name, because a name is something else here. A
|
|
150
|
+
source's name is part of its output, prefixing every column it contributes. A
|
|
151
|
+
label belongs to the store, and points at whichever resolver output you last
|
|
152
|
+
aimed it at.
|
|
153
|
+
|
|
154
|
+
Re-publishing the same label for the same resolver output is a no-op, so
|
|
155
|
+
re-running an unchanged pipeline is safe. Aiming an existing label at a
|
|
156
|
+
*different* resolver output needs `overwrite=True`, because that is how you
|
|
157
|
+
lose track of what a label used to mean.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
label: The label to publish under.
|
|
161
|
+
overwrite: Move the label if it already points somewhere else.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
This resolver, so it chains off `collect()`.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: If this resolver has not been collected.
|
|
168
|
+
ValueError: If `label` already points at a different resolver output and
|
|
169
|
+
`overwrite` is not set.
|
|
170
|
+
"""
|
|
171
|
+
adapter, fp = self._collected()
|
|
172
|
+
existing = adapter.find(label)
|
|
173
|
+
if existing is not None and existing != fp and not overwrite:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"The label '{label}' already points at a different resolver output "
|
|
176
|
+
f"({existing.hex()[:8]}). Pass overwrite=True to move it, or publish "
|
|
177
|
+
"under another label."
|
|
178
|
+
)
|
|
179
|
+
adapter.publish(label, fp)
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
# -- Step contract ----------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _execute(self, adapter: Adapter, fp: Fingerprint) -> None:
|
|
185
|
+
edges = {
|
|
186
|
+
position: adapter.read_model(model._fp)
|
|
187
|
+
for position, model in enumerate(self.inputs)
|
|
188
|
+
}
|
|
189
|
+
clusters = self.resolver_instance.compute_clusters(model_edges=edges)
|
|
190
|
+
|
|
191
|
+
# materialise_resolver_output carries forward every leaf reachable through
|
|
192
|
+
# this resolver's inputs, including records no model formed an edge over
|
|
193
|
+
# (the merge-forward / fall-through requirement).
|
|
194
|
+
|
|
195
|
+
# Deduplicate what is read. Linking every pair of n sources gives n(n-1)
|
|
196
|
+
# (model, record_step) pairs, but only a handful of distinct readings between
|
|
197
|
+
# them, since they share an upstream resolver and cover the same sources.
|
|
198
|
+
# Asking per pair would otherwise repeat the same query a quadratic number of
|
|
199
|
+
# times. `dict.fromkeys` dedupes while keeping lineage order, so the record
|
|
200
|
+
# step is built the same way every run.
|
|
201
|
+
reads = dict.fromkeys(
|
|
202
|
+
read
|
|
203
|
+
for model in self.inputs
|
|
204
|
+
for record_step in model.inputs
|
|
205
|
+
for read in record_step._identifier_reads
|
|
206
|
+
)
|
|
207
|
+
upstream = pl.concat(
|
|
208
|
+
[adapter.read_identifiers(*read) for read in reads],
|
|
209
|
+
how="vertical",
|
|
210
|
+
).unique()
|
|
211
|
+
|
|
212
|
+
# Record which source artifacts this resolver output covers. A resolver's
|
|
213
|
+
# output names its sources, but a store can hold several generations of a
|
|
214
|
+
# name. That is what lets it be read back without the plan that built it.
|
|
215
|
+
# Those names are data, tagging which source each row came from. They are
|
|
216
|
+
# nothing to do with publishing, which is `publish()` and happens after this.
|
|
217
|
+
adapter.store_resolver(
|
|
218
|
+
fp=fp,
|
|
219
|
+
resolver_output=materialise_resolver_output(clusters, upstream),
|
|
220
|
+
sources={source.name: source._fp for source in self.sources},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# -- data -------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def sources(self) -> tuple[Source, ...]:
|
|
227
|
+
"""The sources reachable through this resolver, in lineage order."""
|
|
228
|
+
return tuple(step for step in self.lineage() if isinstance(step, Source))
|
|
229
|
+
|
|
230
|
+
def entities(self, sources: list[str] | None = None) -> pl.DataFrame:
|
|
231
|
+
"""Return `(root, leaf, key, source)`. Collects the plan first if needed.
|
|
232
|
+
|
|
233
|
+
One row per source record. `root` is the entity it resolved to, `leaf` its
|
|
234
|
+
content-addressed record identity, and `key` its key in the original source.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
sources: Restrict to these source names. Defaults to all of them.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
StepNotFound: If `sources` names something this resolver does not read.
|
|
241
|
+
"""
|
|
242
|
+
if not self.is_collected:
|
|
243
|
+
self.collect()
|
|
244
|
+
adapter, fp = self._collected()
|
|
245
|
+
resolver_output = adapter.read_resolver(fp)
|
|
246
|
+
|
|
247
|
+
if sources is None:
|
|
248
|
+
return resolver_output
|
|
249
|
+
|
|
250
|
+
return resolver_output.filter(pl.col("source").is_in(self._named(sources)))
|
|
251
|
+
|
|
252
|
+
def _named(self, sources: list[str]) -> list[str]:
|
|
253
|
+
"""Narrow this resolver's sources to those named, in lineage order.
|
|
254
|
+
|
|
255
|
+
A name that isn't one of them is an error, not an empty result. Asking
|
|
256
|
+
for a source this resolver never read is a mistake in the caller, and silently
|
|
257
|
+
returning nothing is how it stays one.
|
|
258
|
+
"""
|
|
259
|
+
available = [source.name for source in self.sources]
|
|
260
|
+
unknown = [name for name in sources if name not in available]
|
|
261
|
+
if unknown:
|
|
262
|
+
raise StepNotFound(
|
|
263
|
+
f"This resolver does not read {', '.join(repr(n) for n in unknown)}. "
|
|
264
|
+
f"It covers: {', '.join(available)}."
|
|
265
|
+
)
|
|
266
|
+
if not sources:
|
|
267
|
+
raise StepNotFound("No compatible source was found")
|
|
268
|
+
return [name for name in available if name in sources]
|
|
269
|
+
|
|
270
|
+
def get_lookup(self, sources: list[str] | None = None) -> pl.DataFrame:
|
|
271
|
+
"""Return `root` plus one qualified-key column per source.
|
|
272
|
+
|
|
273
|
+
The wide form of `entities()` is a row per entity, joined across sources, with
|
|
274
|
+
nulls where a source has no record in it. This is the table you hand to
|
|
275
|
+
someone who just wants their identifiers lined up.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
sources: Restrict to these source names. Defaults to all of them.
|
|
279
|
+
"""
|
|
280
|
+
resolver_output = self.entities(sources)
|
|
281
|
+
names = self._named(sources) if sources is not None else None
|
|
282
|
+
|
|
283
|
+
# This is never empty. A resolver always has a source, and `_named` raises
|
|
284
|
+
# rather than narrow to none, so there is always a table to start the join
|
|
285
|
+
# from.
|
|
286
|
+
columns = [
|
|
287
|
+
resolver_output.filter(pl.col("source") == source.name).select(
|
|
288
|
+
"root", pl.col("key").alias(source.qualified_key)
|
|
289
|
+
)
|
|
290
|
+
for source in self.sources
|
|
291
|
+
if names is None or source.name in names
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
lookup = columns[0]
|
|
295
|
+
for keys in columns[1:]:
|
|
296
|
+
lookup = lookup.join(keys, on="root", how="full", coalesce=True)
|
|
297
|
+
return lookup
|
|
298
|
+
|
|
299
|
+
def leaf_sets(self, sources: list[str] | None = None) -> list[list[int]]:
|
|
300
|
+
"""Return each entity as a sorted list of record identities.
|
|
301
|
+
|
|
302
|
+
Cluster IDs are dropped, so two resolver outputs over the same records can be
|
|
303
|
+
compared by structure alone. Leaves are deduplicated, so each one appears once
|
|
304
|
+
per key it holds.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
sources: Restrict to these source names. Defaults to all of them.
|
|
308
|
+
"""
|
|
309
|
+
groups = self.entities(sources).group_by("root").agg("leaf")["leaf"].to_list()
|
|
310
|
+
return [sorted(set(group)) for group in groups]
|
|
311
|
+
|
|
312
|
+
def view_entity(self, root: int, merge_fields: bool = False) -> pl.DataFrame:
|
|
313
|
+
"""Return the stored rows for every record in one entity.
|
|
314
|
+
|
|
315
|
+
Values come from the extract cached when each source was collected, not from a
|
|
316
|
+
fresh warehouse read. That is the data the matching actually saw, the same
|
|
317
|
+
rows the reviewer puts on screen, and it means looking at an entity needs no
|
|
318
|
+
warehouse connection.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
root: The entity to look at.
|
|
322
|
+
merge_fields: Collapse the source qualifier on index fields, so a field two
|
|
323
|
+
sources share lands in one column. Key fields stay qualified, since
|
|
324
|
+
they are what tells you which source a row came from.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
KeyError: If no source has a record in that entity.
|
|
328
|
+
"""
|
|
329
|
+
adapter, fp = self._collected()
|
|
330
|
+
resolver_output = self.entities().filter(pl.col("root") == root)
|
|
331
|
+
stored = adapter.resolver_output_sources(fp)
|
|
332
|
+
|
|
333
|
+
rows: list[pl.DataFrame] = []
|
|
334
|
+
key_columns: list[str] = []
|
|
335
|
+
for source in self.sources:
|
|
336
|
+
keys = resolver_output.filter(pl.col("source") == source.name)["key"]
|
|
337
|
+
if keys.len() == 0:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
records, qualified_key = adapter.read_source_records(
|
|
341
|
+
stored[source.name], source.name, keys
|
|
342
|
+
)
|
|
343
|
+
if merge_fields:
|
|
344
|
+
prefix = qualify(source.name)
|
|
345
|
+
records = records.rename(
|
|
346
|
+
{
|
|
347
|
+
column: column.removeprefix(prefix)
|
|
348
|
+
for column in records.columns
|
|
349
|
+
if column != qualified_key
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
key_columns.append(qualified_key)
|
|
354
|
+
rows.append(records)
|
|
355
|
+
|
|
356
|
+
if not rows:
|
|
357
|
+
raise KeyError(f"Entity {root} not available")
|
|
358
|
+
|
|
359
|
+
# Coerce fields to their common super-type, and lead with the keys.
|
|
360
|
+
joined = pl.concat(rows, how="diagonal_relaxed")
|
|
361
|
+
remaining = [column for column in joined.columns if column not in key_columns]
|
|
362
|
+
return joined.select(*key_columns, *remaining)
|
|
363
|
+
|
|
364
|
+
def lookup_key(
|
|
365
|
+
self, from_source: str, to_sources: list[str], key: str
|
|
366
|
+
) -> dict[str, list[str]]:
|
|
367
|
+
"""Find the keys in `to_sources` that resolve to the same entity as `key`.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
from_source: The source `key` belongs to.
|
|
371
|
+
to_sources: The sources to find matching keys in.
|
|
372
|
+
key: The key to look up.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Source name → matching keys, including `from_source` itself.
|
|
376
|
+
"""
|
|
377
|
+
resolver_output = self.entities()
|
|
378
|
+
origin = resolver_output.filter(
|
|
379
|
+
(pl.col("source") == from_source) & (pl.col("key") == key)
|
|
380
|
+
)
|
|
381
|
+
if origin.height == 0:
|
|
382
|
+
raise StepNotFound(f"Key '{key}' not found in source '{from_source}'")
|
|
383
|
+
|
|
384
|
+
cluster = resolver_output.filter(pl.col("root") == origin["root"][0])
|
|
385
|
+
|
|
386
|
+
def keys_in(source_name: str) -> list[str]:
|
|
387
|
+
return cluster.filter(pl.col("source") == source_name)["key"].to_list()
|
|
388
|
+
|
|
389
|
+
return {from_source: keys_in(from_source)} | {
|
|
390
|
+
target: keys_in(target) for target in to_sources
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
# -- RecordStep contract -------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def _read_cache(self, adapter: Adapter) -> pl.DataFrame:
|
|
396
|
+
"""Derive the table, this resolver's sources with `id` set to the entity root.
|
|
397
|
+
|
|
398
|
+
A re-derivable join over artifacts already stored (the source extracts and this
|
|
399
|
+
resolver's output), so nothing is materialised for it.
|
|
400
|
+
"""
|
|
401
|
+
if self._fp is None: # collect orders upstream first
|
|
402
|
+
raise RuntimeError(
|
|
403
|
+
"This resolver has not been collected. Call collect() first."
|
|
404
|
+
)
|
|
405
|
+
return build_record_step(adapter, self.sources, self)
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def _identifier_reads(self) -> tuple[IdentifierRead, ...]:
|
|
409
|
+
return tuple((source._fp, source.name, self._fp) for source in self.sources)
|