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
matchlab/sources.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Source — the leaf of a plan.
|
|
2
|
+
|
|
3
|
+
A source reads rows from a warehouse and content-addresses them. It takes no inputs,
|
|
4
|
+
so it is where raw data, and therefore non-determinism, enters a plan. Its spec key
|
|
5
|
+
includes a hash of the data it read, which is what makes a freshly constructed `Source`
|
|
6
|
+
pick up warehouse changes while an existing object memoises its read.
|
|
7
|
+
|
|
8
|
+
**The extract/transform is the whole declaration.** Every column it returns is part of
|
|
9
|
+
the record, so every column except the key contributes to that record's identity. Two
|
|
10
|
+
rows are the same leaf when they agree on all of them. A column
|
|
11
|
+
you do not want to affect identity is a column you should not select, and a type you
|
|
12
|
+
want pinned is a `cast` in the SELECT.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
from collections.abc import Callable, Generator, Iterable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
import polars as pl
|
|
21
|
+
import pyarrow.parquet as pq
|
|
22
|
+
|
|
23
|
+
from matchlab.adapters import Adapter, Fingerprint
|
|
24
|
+
from matchlab.core.dataframes import (
|
|
25
|
+
DataFrameClass,
|
|
26
|
+
DataFrameType,
|
|
27
|
+
qualify,
|
|
28
|
+
validate_col_prefix,
|
|
29
|
+
)
|
|
30
|
+
from matchlab.core.hash import HashMethod, hash_arrow_table, hash_rows
|
|
31
|
+
from matchlab.core.kinds import StepKind
|
|
32
|
+
from matchlab.core.logging import logger
|
|
33
|
+
from matchlab.core.resolver_output import leaf_id
|
|
34
|
+
from matchlab.core.sql import SQLQuery
|
|
35
|
+
from matchlab.locations import Location
|
|
36
|
+
from matchlab.recordstep import IdentifierRead, RecordStep, build_record_step
|
|
37
|
+
from matchlab.specs import SourceSpec
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Source(RecordStep):
|
|
41
|
+
"""A warehouse table, extracted and content-addressed.
|
|
42
|
+
|
|
43
|
+
A source is a `RecordStep`. Read directly, its records are its extract, with `id`
|
|
44
|
+
set to each row's content-addressed leaf, so a model can match over it with no
|
|
45
|
+
intervening step.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
kind: ClassVar[StepKind] = StepKind.SOURCE
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
location: Location,
|
|
53
|
+
name: str,
|
|
54
|
+
extract_transform: SQLQuery,
|
|
55
|
+
key_field: str,
|
|
56
|
+
validate_etl: bool = True,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Define a source.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
location: Where the data lives.
|
|
62
|
+
name: The source's name within the plan. Prefixes every column this
|
|
63
|
+
source contributes, so it must be usable in a SQL identifier.
|
|
64
|
+
extract_transform: SQL producing the rows to index. Every column it
|
|
65
|
+
returns other than the key contributes to record identity.
|
|
66
|
+
key_field: The name of the unique identifier field. Read as a string
|
|
67
|
+
whatever the warehouse returns it as.
|
|
68
|
+
validate_etl: Validate the extract/transform SQL up front.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If the name could not prefix a SQL identifier, or if
|
|
72
|
+
`key_field` is not a column name.
|
|
73
|
+
"""
|
|
74
|
+
validate_col_prefix(name)
|
|
75
|
+
|
|
76
|
+
# A source's name is not a way of finding it later, the way a published
|
|
77
|
+
# resolver output's is. It prefixes every column this source contributes and
|
|
78
|
+
# tags its rows in a resolver's output, so it is part of the output. That is
|
|
79
|
+
# why it is required, lives on the source itself, and is hashed into the
|
|
80
|
+
# fingerprint.
|
|
81
|
+
self.name = name
|
|
82
|
+
super().__init__()
|
|
83
|
+
|
|
84
|
+
if not isinstance(key_field, str):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"key_field must be a column name, got {type(key_field).__name__}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if validate_etl:
|
|
90
|
+
location.validate_extract_transform(extract_transform)
|
|
91
|
+
|
|
92
|
+
self.location = location
|
|
93
|
+
self.extract_transform = extract_transform
|
|
94
|
+
self.key_field = key_field
|
|
95
|
+
|
|
96
|
+
# Memoised warehouse read: (extract, hashes). Populated on first fingerprint.
|
|
97
|
+
self._read: tuple[pl.DataFrame, pl.DataFrame] | None = None
|
|
98
|
+
|
|
99
|
+
# -- spec -------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def __str__(self) -> str:
|
|
102
|
+
"""A source is drawn with its name, since it has one."""
|
|
103
|
+
return f"{self.kind} '{self.name}'"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def spec(self) -> SourceSpec:
|
|
107
|
+
"""The serialisable spec for this source."""
|
|
108
|
+
return SourceSpec(
|
|
109
|
+
name=self.name,
|
|
110
|
+
extract_transform=self.extract_transform,
|
|
111
|
+
key_field=self.key_field,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# -- column naming ----------------------------------------------------------------
|
|
115
|
+
#
|
|
116
|
+
# A record step built over several sources qualifies every column with the source
|
|
117
|
+
# it came from, so `company` from `crn` becomes `crn_company`. These say what a
|
|
118
|
+
# column will be called once that has happened, which is how you refer to it in a
|
|
119
|
+
# cleaning expression or a model's settings, before anything has been collected.
|
|
120
|
+
|
|
121
|
+
def f(self, fields: str | Iterable[str]) -> str | list[str]:
|
|
122
|
+
"""Prefix one or more field names with this source's name."""
|
|
123
|
+
if isinstance(fields, str):
|
|
124
|
+
return qualify(self.name, fields)
|
|
125
|
+
return [qualify(self.name, field) for field in fields]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def qualified_key(self) -> str:
|
|
129
|
+
"""This source's key field, prefixed with the source name."""
|
|
130
|
+
return qualify(self.name, self.key_field)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def index_fields(self) -> list[str]:
|
|
134
|
+
"""Every column the extract returns except the key, in sorted order.
|
|
135
|
+
|
|
136
|
+
Read from the warehouse rather than declared, so it cannot drift from the SQL.
|
|
137
|
+
"""
|
|
138
|
+
extract, _ = self._read_warehouse()
|
|
139
|
+
return sorted(column for column in extract.columns if column != self.key_field)
|
|
140
|
+
|
|
141
|
+
# -- warehouse access -------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def fetch(
|
|
144
|
+
self,
|
|
145
|
+
qualify_names: bool = False,
|
|
146
|
+
batch_size: int | None = None,
|
|
147
|
+
return_type: DataFrameType = DataFrameType.POLARS,
|
|
148
|
+
keys: list[str] | None = None,
|
|
149
|
+
) -> Generator[DataFrameClass, None, None]:
|
|
150
|
+
"""Apply the extract/transform and yield the resulting rows in batches."""
|
|
151
|
+
rename: Callable[[str], str] | None = None
|
|
152
|
+
if qualify_names:
|
|
153
|
+
|
|
154
|
+
def rename(column: str) -> str:
|
|
155
|
+
return qualify(self.name, column)
|
|
156
|
+
|
|
157
|
+
# Keys are always strings, whatever the warehouse returns them as. Every
|
|
158
|
+
# other column is read as the warehouse types it. Pin one with a `cast` in
|
|
159
|
+
# the extract/transform if you need it stable across drivers.
|
|
160
|
+
selection = (self.key_field, keys) if keys else None
|
|
161
|
+
yield from self.location.execute(
|
|
162
|
+
extract_transform=self.extract_transform,
|
|
163
|
+
schema_overrides={self.key_field: pl.String},
|
|
164
|
+
rename=rename,
|
|
165
|
+
batch_size=batch_size,
|
|
166
|
+
return_type=return_type,
|
|
167
|
+
**({"keys": selection} if selection else {}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def sample(
|
|
171
|
+
self, n: int = 100, return_type: DataFrameType = DataFrameType.POLARS
|
|
172
|
+
) -> DataFrameClass:
|
|
173
|
+
"""Peek at the first `n` rows without collecting."""
|
|
174
|
+
return next(self.fetch(batch_size=n, return_type=return_type))
|
|
175
|
+
|
|
176
|
+
def _read_warehouse(self) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
177
|
+
"""Read the source and content-address every row. Memoised.
|
|
178
|
+
|
|
179
|
+
Every column except the key contributes to the row hash, so two rows are the
|
|
180
|
+
same leaf exactly when the extract returns identical values for both. Identity
|
|
181
|
+
is content, not the key. See `matchlab.core.resolver_output.leaf_id` for why
|
|
182
|
+
that is the correct anchor for evaluation rather than a storage trick.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
`(extract, hashes)`, the raw rows, and a `hash → keys` index in which
|
|
186
|
+
byte-identical rows share a hash, so records a judge cannot tell apart share
|
|
187
|
+
a leaf.
|
|
188
|
+
"""
|
|
189
|
+
if self._read is not None:
|
|
190
|
+
return self._read
|
|
191
|
+
|
|
192
|
+
logger.info("Reading from the warehouse", prefix=self.name)
|
|
193
|
+
key = self.key_field
|
|
194
|
+
|
|
195
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
196
|
+
path = Path(tmp) / f"{self.name}.parquet"
|
|
197
|
+
writer = None
|
|
198
|
+
for batch in self.fetch(return_type=DataFrameType.ARROW):
|
|
199
|
+
if writer is None:
|
|
200
|
+
writer = pq.ParquetWriter(path, schema=batch.schema)
|
|
201
|
+
writer.write_table(batch)
|
|
202
|
+
if writer is None:
|
|
203
|
+
raise ValueError(f"Source '{self.name}' returned no rows")
|
|
204
|
+
writer.close()
|
|
205
|
+
|
|
206
|
+
frames: list[pl.DataFrame] = []
|
|
207
|
+
hashed: list[pl.DataFrame] = []
|
|
208
|
+
parquet = pq.ParquetFile(path)
|
|
209
|
+
for group in range(parquet.num_row_groups):
|
|
210
|
+
batch = pl.from_arrow(parquet.read_row_group(group))
|
|
211
|
+
if batch[key].is_null().any():
|
|
212
|
+
raise ValueError(f"Source '{self.name}' has null keys")
|
|
213
|
+
frames.append(batch)
|
|
214
|
+
index = sorted(column for column in batch.columns if column != key)
|
|
215
|
+
if not index:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"Source '{self.name}' returns only its key field. The "
|
|
218
|
+
"extract/transform must select at least one other column."
|
|
219
|
+
)
|
|
220
|
+
# XXH3_128 rather than SHA-256. `leaf_id`
|
|
221
|
+
# slices the row hash to 8 bytes, so a cryptographic digest's strength
|
|
222
|
+
# is discarded before it identifies anything, and the 128 bits that
|
|
223
|
+
# survive into `_spec_key` are ample for telling rows of one table
|
|
224
|
+
# apart. It is ~7x faster, on a path every collect pays, because
|
|
225
|
+
# `_spec_key` re-reads and re-hashes the warehouse even when the
|
|
226
|
+
# artifact is cached.
|
|
227
|
+
row_hashes = hash_rows(
|
|
228
|
+
df=batch, columns=index, method=HashMethod.XXH3_128
|
|
229
|
+
)
|
|
230
|
+
hashed.append(
|
|
231
|
+
batch.select(pl.col(key).alias("keys")).with_columns(
|
|
232
|
+
row_hashes.alias("hash")
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
extract = pl.concat(frames)
|
|
237
|
+
hashes = pl.concat(hashed).group_by("hash").agg(pl.col("keys"))
|
|
238
|
+
self._read = (extract, hashes)
|
|
239
|
+
return self._read
|
|
240
|
+
|
|
241
|
+
def leaves(self) -> pl.DataFrame:
|
|
242
|
+
"""Return `(key, leaf)`, each source key mapped to its leaf cluster."""
|
|
243
|
+
_, hashes = self._read_warehouse()
|
|
244
|
+
return hashes.explode("keys", empty_as_null=True).select(
|
|
245
|
+
pl.col("keys").alias("key"),
|
|
246
|
+
leaf_id(pl.col("hash")).alias("leaf"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# -- Step contract ----------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _spec_key(self) -> bytes:
|
|
252
|
+
"""The spec plus a content hash of the data read.
|
|
253
|
+
|
|
254
|
+
Including the data hash is what lets a plan detect that the warehouse changed.
|
|
255
|
+
A new `Source` object re-reads and gets a different key, invalidating
|
|
256
|
+
everything downstream of it.
|
|
257
|
+
|
|
258
|
+
Hashing `hashes` alone is sufficient because every non-key column feeds the row
|
|
259
|
+
hash, and `hashes` records which keys carry each one. A change to any selected
|
|
260
|
+
column therefore moves the fingerprint. This was not true while a separate list
|
|
261
|
+
of index fields could be narrower than the extract. A column outside it changed
|
|
262
|
+
the stored extract without touching the fingerprint, so the source cache-hit,
|
|
263
|
+
never re-stored, and downstream record steps kept reading the stale value.
|
|
264
|
+
"""
|
|
265
|
+
_, hashes = self._read_warehouse()
|
|
266
|
+
return super()._spec_key() + hash_arrow_table(hashes.to_arrow())
|
|
267
|
+
|
|
268
|
+
def _execute(self, adapter: Adapter, fp: Fingerprint) -> None:
|
|
269
|
+
extract, _ = self._read_warehouse()
|
|
270
|
+
adapter.store_source(
|
|
271
|
+
fp=fp,
|
|
272
|
+
key_field=self.key_field,
|
|
273
|
+
extract=extract,
|
|
274
|
+
leaves=self.leaves(),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# -- RecordStep contract -------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def _read_cache(self, adapter: Adapter) -> pl.DataFrame:
|
|
280
|
+
"""Return this source's records with each row's leaf as `id`.
|
|
281
|
+
|
|
282
|
+
Derived from the extract and leaves stored by `_execute`, a cheap join rather
|
|
283
|
+
than a separately cached artifact. Reading a source on its own means `id` is
|
|
284
|
+
the leaf, so there is no resolver in the read.
|
|
285
|
+
"""
|
|
286
|
+
return build_record_step(adapter, (self,), None)
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def _identifier_reads(self) -> tuple[IdentifierRead, ...]:
|
|
290
|
+
"""Read directly, so `id` is the leaf and no resolver enters the read."""
|
|
291
|
+
return ((self._fp, self.name, None),)
|
matchlab/specs.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Step specs — what a fingerprint hashes.
|
|
2
|
+
|
|
3
|
+
One model per step kind, and `Step._spec_key` hashes it to address that step's artifact.
|
|
4
|
+
What belongs in a spec is therefore decided entirely by the fingerprint invariant.
|
|
5
|
+
**It must carry everything the step's output depends on, and nothing else.** Omit
|
|
6
|
+
something that changes the output, and `collect` hands back a stale artifact without
|
|
7
|
+
re-running. Include something that doesn't, and the step re-runs for nothing, along
|
|
8
|
+
with everything below it.
|
|
9
|
+
|
|
10
|
+
That rules out two things. **Edges**, because `_fingerprint` already folds in the
|
|
11
|
+
parents' fingerprints. Describing an input here would record it twice, and a rename
|
|
12
|
+
would then invalidate a subtree producing identical bytes. **Names** are also excluded,
|
|
13
|
+
with one exception: `SourceSpec.name` is in the output it prefixes. A setting that must
|
|
14
|
+
point at an input points at its position instead (`ComponentsSettings.thresholds`).
|
|
15
|
+
|
|
16
|
+
A spec is therefore not the serialisation format. Rebuilding a plan needs the edges
|
|
17
|
+
that identifying a step must exclude. `matchlab.document` carries them alongside it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import textwrap
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pydantic import (
|
|
25
|
+
BaseModel,
|
|
26
|
+
ConfigDict,
|
|
27
|
+
Field,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from matchlab.core.sql import SQLQuery
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModelType(StrEnum):
|
|
34
|
+
"""Whether a model is a deduper or a linker."""
|
|
35
|
+
|
|
36
|
+
LINKER = "linker"
|
|
37
|
+
DEDUPER = "deduper"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SourceSpec(BaseModel):
|
|
41
|
+
"""Specification of a source: what it extracts, and what keys it.
|
|
42
|
+
|
|
43
|
+
There is no separate list of indexed fields. The extract/transform is the single
|
|
44
|
+
declaration of what a source *is*. Every column it returns is part of the record,
|
|
45
|
+
and therefore part of that record's identity. A column you do not want to affect
|
|
46
|
+
identity is a column you should not select.
|
|
47
|
+
|
|
48
|
+
**Not where the rows came from.** A source's key already folds in a content hash of
|
|
49
|
+
what it actually read, so anything a location could change about the output is
|
|
50
|
+
caught by the data itself.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(frozen=True)
|
|
54
|
+
|
|
55
|
+
name: str = Field(
|
|
56
|
+
description=(
|
|
57
|
+
"The source's name within the plan. Part of the spec because it is "
|
|
58
|
+
"part of the output: it prefixes every column this source contributes "
|
|
59
|
+
"and tags its rows in a resolver's output."
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
extract_transform: SQLQuery = Field(
|
|
63
|
+
description=(
|
|
64
|
+
"Logic to extract and transform data from the source. "
|
|
65
|
+
"Language is location dependent."
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
key_field: str = Field(
|
|
69
|
+
description=textwrap.dedent("""
|
|
70
|
+
The name of the key field. This is the source's key for unique
|
|
71
|
+
entities, such as a primary key in a relational database.
|
|
72
|
+
|
|
73
|
+
Keys are always read as strings, whatever the warehouse returns.
|
|
74
|
+
|
|
75
|
+
For example, if the source describes companies, it may have used
|
|
76
|
+
a Companies House number as its key.
|
|
77
|
+
|
|
78
|
+
This key is ALWAYS correct. It should be something generated and
|
|
79
|
+
owned by the source being indexed.
|
|
80
|
+
|
|
81
|
+
For example, your organisation's CRM ID is a key field within the CRM.
|
|
82
|
+
|
|
83
|
+
A CRM ID entered by hand in another dataset shouldn't be used
|
|
84
|
+
as a key field.
|
|
85
|
+
"""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TransformSpec(BaseModel):
|
|
90
|
+
"""Specification of a transform, naming the transformer that reshapes the step."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(frozen=True)
|
|
93
|
+
|
|
94
|
+
transformer_class: str = Field(
|
|
95
|
+
description="The registered name of the Transformer subclass."
|
|
96
|
+
)
|
|
97
|
+
transformer_settings: dict[str, Any] = Field(
|
|
98
|
+
description="That transformer's configuration, dumped to JSON."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ModelSpec(BaseModel):
|
|
103
|
+
"""Specification of a deduper or linker."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(frozen=True)
|
|
106
|
+
|
|
107
|
+
model_type: ModelType = Field(description="Whether this is a deduper or a linker.")
|
|
108
|
+
model_class: str = Field(
|
|
109
|
+
description="The registered name of the Deduper or Linker subclass."
|
|
110
|
+
)
|
|
111
|
+
model_settings: dict[str, Any] = Field(
|
|
112
|
+
description="That class's settings, dumped to JSON."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ResolverSpec(BaseModel):
|
|
117
|
+
"""Specification of a resolver over one or more models."""
|
|
118
|
+
|
|
119
|
+
model_config = ConfigDict(frozen=True)
|
|
120
|
+
|
|
121
|
+
resolver_class: str = Field(
|
|
122
|
+
description="The registered name of the ResolverMethod subclass."
|
|
123
|
+
)
|
|
124
|
+
resolver_settings: dict[str, Any] = Field(
|
|
125
|
+
description=(
|
|
126
|
+
"That class's settings, dumped to JSON. Settings that point at one of the "
|
|
127
|
+
"resolver's inputs key by the input's position, so no name appears here."
|
|
128
|
+
)
|
|
129
|
+
)
|