matchlab 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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/document.py CHANGED
@@ -158,15 +158,19 @@ class PlanDocument(BaseModel):
158
158
  return {name for node in self.steps for name in node.resources.values()}
159
159
 
160
160
 
161
- def dump(root: Step) -> PlanDocument:
161
+ def dump(root: Step, *, indent: int | None = None) -> str:
162
162
  """Describe `root` and everything it reads as a portable document.
163
163
 
164
+ JSON rather than an object, because a document exists to be put somewhere: a
165
+ column, an object store, a request body, a file.
166
+
164
167
  Args:
165
168
  root: The apex of the plan. Only what is reachable upstream of it is included,
166
169
  exactly as `collect()` would run.
170
+ indent: How the JSON is laid out. `None`, the default, is compact.
167
171
 
168
172
  Returns:
169
- The document. It holds no resource, no credentials and no data.
173
+ The document, as JSON.
170
174
 
171
175
  Raises:
172
176
  ResourceError: If a resource was passed without a name, so there is nothing for
@@ -174,23 +178,9 @@ def dump(root: Step) -> PlanDocument:
174
178
  earlier, when the plan is built.
175
179
  """
176
180
  ordered = walk(root)
177
- _check_resources(ordered)
178
- position = {id(step): index for index, step in enumerate(ordered)}
179
- return PlanDocument(
180
- steps=tuple(
181
- _node(step, tuple(position[id(parent)] for parent in step.parents))
182
- for step in ordered
183
- )
184
- )
185
-
186
-
187
- def _check_resources(steps: list[Step]) -> None:
188
- """Reject a plan whose resources have no names to record.
189
181
 
190
- Raises:
191
- ResourceError: If a resource is unnamed.
192
- """
193
- for position, step in enumerate(steps):
182
+ # Check resources
183
+ for position, step in enumerate(ordered):
194
184
  for field, resource in step._resources().items():
195
185
  if resource.name is None:
196
186
  raise ResourceError(
@@ -199,6 +189,15 @@ def _check_resources(steps: list[Step]) -> None:
199
189
  f'{field}=Resource("some_name", ...).'
200
190
  )
201
191
 
192
+ position = {id(step): index for index, step in enumerate(ordered)}
193
+ document = PlanDocument(
194
+ steps=tuple(
195
+ _node(step, tuple(position[id(parent)] for parent in step.parents))
196
+ for step in ordered
197
+ )
198
+ )
199
+ return document.model_dump_json(indent=indent)
200
+
202
201
 
203
202
  def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
204
203
  """Describe one step, checking its spec is the one its kind implies."""
@@ -216,61 +215,68 @@ def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
216
215
  )
217
216
 
218
217
 
219
- def load(document: PlanDocument, resources: Mapping[str, Any]) -> Step:
218
+ def load(document: str | bytes, resources: Mapping[str, Any]) -> Step:
220
219
  """Rebuild a plan from a document, returning its apex.
221
220
 
222
221
  Nothing is collected. This reconstructs the same lazy plan, so the returned step
223
222
  fingerprints identically to the one that was dumped, given the same data.
224
223
 
225
224
  Args:
226
- document: A dumped plan.
225
+ document: The JSON `dump` produced.
227
226
  resources: Name to object, for every resource the document names.
228
- `document.required_resources()` lists them.
229
227
 
230
228
  Returns:
231
229
  The plan's apex, the last step in the document.
232
230
 
233
231
  Raises:
232
+ ValidationError: If the JSON does not describe a plan document.
234
233
  ValueError: If the document is empty, names an unregistered location class, or
235
234
  wires a step to an input of the wrong kind.
236
235
  ResourceError: If a named resource was not supplied.
237
236
  """
238
- if not document.steps:
237
+ parsed = PlanDocument.model_validate_json(document)
238
+ if not parsed.steps:
239
239
  raise ValueError("A plan document needs at least one step")
240
240
 
241
+ _check_supplied(parsed, resources)
242
+
241
243
  built: list[Step] = []
242
- for position, node in enumerate(document.steps):
244
+ for position, node in enumerate(parsed.steps):
243
245
  built.append(
244
246
  _rebuild(node, position, [built[i] for i in node.inputs], resources)
245
247
  )
246
248
  return built[-1]
247
249
 
248
250
 
249
- def _bind(
250
- node: StepNode, position: int, supplied: Mapping[str, Any]
251
- ) -> dict[str, Resource]:
252
- """Turn a node's `field -> resource name` map into bound resources.
251
+ def _check_supplied(document: PlanDocument, supplied: Mapping[str, Any]) -> None:
252
+ """Reject a load missing any resource the document names, naming them all.
253
253
 
254
254
  Raises:
255
255
  ResourceError: If the document names a resource `supplied` does not have.
256
256
  """
257
- bound: dict[str, Resource] = {}
258
- for field, name in node.resources.items():
259
- if name not in supplied:
260
- raise ResourceError(
261
- f"Step {position} ({node.kind}) needs resource '{name}' for '{field}', "
262
- f"which was not supplied. Pass it when loading: "
263
- f"resources={{'{name}': ...}}."
264
- )
265
- bound[field] = Resource(name, supplied[name])
266
- return bound
257
+ missing: dict[str, str] = {}
258
+ for position, node in enumerate(document.steps):
259
+ for field, name in node.resources.items():
260
+ if name not in supplied and name not in missing:
261
+ missing[name] = f"step {position} ({node.kind}) reads it as '{field}'"
262
+ if not missing:
263
+ return
264
+
265
+ wanted = "\n".join(f" '{name}' — {where}" for name, where in missing.items())
266
+ example = ", ".join(f"'{name}': ..." for name in missing)
267
+ raise ResourceError(
268
+ f"This plan needs resources that were not supplied:\n{wanted}\n"
269
+ f"Pass them when loading: resources={{{example}}}."
270
+ )
267
271
 
268
272
 
269
273
  def _rebuild(
270
274
  node: StepNode, position: int, inputs: list[Step], supplied: Mapping[str, Any]
271
275
  ) -> Step:
272
276
  """Rebuild one step from its node and its already-rebuilt inputs."""
273
- resources = _bind(node, position, supplied)
277
+ resources = {
278
+ field: Resource(name, supplied[name]) for field, name in node.resources.items()
279
+ }
274
280
  match node.kind:
275
281
  case StepKind.SOURCE:
276
282
  spec = _expect(node, position, SourceSpec)
@@ -174,21 +174,52 @@ class SplinkLinker(Linker):
174
174
  _id_dtype_l: pl.DataType
175
175
  _id_dtype_r: pl.DataType
176
176
 
177
+ @staticmethod
178
+ def _schema_summary(data: pl.DataFrame) -> str:
179
+ """Render a compact column-to-dtype summary for diagnostics."""
180
+ return (
181
+ ", ".join(f"{column}={dtype}" for column, dtype in data.schema.items())
182
+ or "<empty>"
183
+ )
184
+
185
+ @classmethod
186
+ def _conformancy_error(cls, left: pl.DataFrame, right: pl.DataFrame) -> ValueError:
187
+ """Build a detailed error describing how the two inputs differ."""
188
+ left_only = [column for column in left.columns if column not in right.columns]
189
+ right_only = [column for column in right.columns if column not in left.columns]
190
+ shared = [column for column in left.columns if column in right.columns]
191
+ differing_dtypes = [
192
+ f"{column}: left={left.schema[column]}, right={right.schema[column]}"
193
+ for column in shared
194
+ if left.schema[column] != right.schema[column]
195
+ ]
196
+
197
+ details = [
198
+ "SplinkLinker requires input data to be conformant, meaning they "
199
+ "share the same column names and data formats.",
200
+ f"Left schema: {cls._schema_summary(left)}",
201
+ f"Right schema: {cls._schema_summary(right)}",
202
+ ]
203
+ if left_only:
204
+ details.append(f"Columns only on left: {left_only}")
205
+ if right_only:
206
+ details.append(f"Columns only on right: {right_only}")
207
+ if differing_dtypes:
208
+ details.append("Differing dtypes: " + "; ".join(differing_dtypes))
209
+ return ValueError("\n".join(details))
210
+
177
211
  def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
178
212
  """Build the Splink linker over left and right, and run its training functions.
179
213
 
180
214
  Runs each function in `settings.linker_training_functions`, in order, against
181
215
  the built linker. `link()` then just predicts against the trained state.
182
216
  """
183
- if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
184
- raise ValueError(
185
- "SplinkLinker requires input data to be conformant, meaning they "
186
- "share the same column names and data formats."
187
- )
188
-
189
217
  self._id_dtype_l = left[self.left_id].dtype
190
218
  self._id_dtype_r = right[self.right_id].dtype
191
219
 
220
+ if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
221
+ raise self._conformancy_error(left, right)
222
+
192
223
  # Convert to pandas for Splink compatibility
193
224
  left_pd = left.with_columns(pl.col(self.left_id).cast(pl.String)).to_pandas()
194
225
  right_pd = right.with_columns(pl.col(self.right_id).cast(pl.String)).to_pandas()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matchlab
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev4
4
4
  Summary: A local-first library for building, running and evaluating entity resolution pipelines.
5
5
  Author: Department for Business and Trade
6
6
  Project-URL: Documentation, https://uktrade.github.io/matchlab/
@@ -1,6 +1,6 @@
1
1
  matchlab/__init__.py,sha256=vjjzGMDlKk1D7GPHp5Yk8LqFs7yhQw8VANVtJh9HK_U,2419
2
2
  matchlab/cli.py,sha256=Ka-clRCiPnxI5jmehjGCgdNyADcPFcnl3cuPI1rynYg,6209
3
- matchlab/document.py,sha256=M6A8N353c-7vIi3NrXPM2yGoHOGCinrH65_5yJmaYFc,14503
3
+ matchlab/document.py,sha256=rCnGJxdu83Mqbf5PnIjda2mVM7StW3IXdMFb9MPx6nE,14927
4
4
  matchlab/lineage.py,sha256=C2y2gclA-ehh2Yh45aLLaAwMgBZOK6tZmLgxjvycl-I,8865
5
5
  matchlab/progress.py,sha256=t2PAj0YG2vAHguMFn9J0NL07R2K6wEnAkGELQpFNYNM,21330
6
6
  matchlab/recordstep.py,sha256=E1-JkXsqcPmrlQw7AM-eDfHPcznLX0e_SzlVrTsADEA,7637
@@ -41,7 +41,7 @@ matchlab/models/dedupers/naive.py,sha256=fP8PFKXDDvNmCMhWPLZQO9KWJoagMIDBq-qYVdU
41
41
  matchlab/models/linkers/__init__.py,sha256=qbj6hITLFBuONO0_fn3yPknTUAWjzWcFSQzIyi75Rmc,339
42
42
  matchlab/models/linkers/base.py,sha256=Wp4Eym5ZyDy3nBeaFVLaHDgB4rC2CSV7VFclPD5PkAI,1488
43
43
  matchlab/models/linkers/deterministic.py,sha256=TQmwy9tPL0F7BStpoQaET6xTbyt1znujebzV7IB3CG0,8735
44
- matchlab/models/linkers/splinklinker.py,sha256=O2Ds0dhnozPAOOi2-CoyM8_ED9Wxtl5B6Kqt415WEl8,10103
44
+ matchlab/models/linkers/splinklinker.py,sha256=iB3h6jSGCzo4HsF3P0mOKt0YqTvq3deukz6VaFb6a6A,11513
45
45
  matchlab/models/linkers/weighteddeterministic.py,sha256=sD2QosF4IJMQwUwmuWzDKCr6Cta7RZyjS43I15WMtTI,4050
46
46
  matchlab/resolvers/__init__.py,sha256=QuWZ6zt2QK7tWn9lr83iYUTkNQxwtAM9PITaoJOOpPs,210
47
47
  matchlab/resolvers/base.py,sha256=s8u3uDZEln9FadCLEc6nxZZICu3KgIP4ShSe-PxruZM,2281
@@ -68,9 +68,9 @@ matchlab/transformers/explode.py,sha256=k8Q__0aFdntyGxkffu2KWSJmCVCu7ImvJtRt-32Q
68
68
  matchlab/transformers/group.py,sha256=oPswcSUdj_2W2WCb0gJ51YSWU3qI3MVfZ3aYw5EXDl0,2374
69
69
  matchlab/transformers/select.py,sha256=s3NmMgL5IS7ifeIhj5ABQJ46XULW6s_kQKASOq7G0sQ,1125
70
70
  matchlab/transformers/transform.py,sha256=KlzKlbEvA5tF6wBSK9RuXkHLd6gckfhDsT6SJQa3atQ,7007
71
- matchlab-0.1.0.dev2.dist-info/licenses/LICENSE,sha256=7fhYMrjTXiy7Sf-9lBtZHNc4IjHTcfqmGW0AcjFMJJo,1090
72
- matchlab-0.1.0.dev2.dist-info/METADATA,sha256=ajzBLXJWFDhpXmJaOSLnqGlBacwzUQV6MjghGFoccP8,3268
73
- matchlab-0.1.0.dev2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
74
- matchlab-0.1.0.dev2.dist-info/entry_points.txt,sha256=giDdKIeNDxGNWrvoVuLA5mtQjwStupSwxDYmrRjM9Rs,47
75
- matchlab-0.1.0.dev2.dist-info/top_level.txt,sha256=1ZVLg12gZgkOUbN5CfX-KmLTbHGRbZqFEZPqrfRVN-Y,9
76
- matchlab-0.1.0.dev2.dist-info/RECORD,,
71
+ matchlab-0.1.0.dev4.dist-info/licenses/LICENSE,sha256=7fhYMrjTXiy7Sf-9lBtZHNc4IjHTcfqmGW0AcjFMJJo,1090
72
+ matchlab-0.1.0.dev4.dist-info/METADATA,sha256=lbnW3f0uznFSzADcZkaA4AJ7H3p7TnxCR8Vn_PuX0qA,3268
73
+ matchlab-0.1.0.dev4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
74
+ matchlab-0.1.0.dev4.dist-info/entry_points.txt,sha256=giDdKIeNDxGNWrvoVuLA5mtQjwStupSwxDYmrRjM9Rs,47
75
+ matchlab-0.1.0.dev4.dist-info/top_level.txt,sha256=1ZVLg12gZgkOUbN5CfX-KmLTbHGRbZqFEZPqrfRVN-Y,9
76
+ matchlab-0.1.0.dev4.dist-info/RECORD,,