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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
matchlab/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """The `matchlab` command.
2
+
3
+ Two commands. A terminal does two things better than a Python prompt. It tells you
4
+ what version you have, and it gives you a full-screen reviewer.
5
+
6
+ `review` takes a `module:attribute` naming a resolver in your code. With `--store`,
7
+ it instead takes the label of a resolver output already published. The second form
8
+ needs no plan and no warehouse. A stored resolver output knows which source artifacts
9
+ it covers, and their extracts hold the values. Name more than one target and you
10
+ review their merged clusters.
11
+ """
12
+
13
+ import argparse
14
+ import importlib
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from matchlab.resolvers import Resolver
22
+
23
+
24
+ def _load_target(target: str) -> "Resolver":
25
+ """Import `module:attribute` and return the resolver it names.
26
+
27
+ The attribute may be a `Resolver`, or a callable returning one. The callable form
28
+ is useful when building the plan needs a warehouse connection you'd rather not
29
+ open at import time. A `Resolver` isn't callable, so the two never blur.
30
+
31
+ Raises:
32
+ SystemExit: If the target can't be imported or isn't a resolver.
33
+ """
34
+ from matchlab.resolvers import Resolver # noqa: PLC0415 - keeps startup cheap
35
+
36
+ if ":" not in target:
37
+ raise SystemExit(
38
+ f"Target '{target}' should look like 'module:attribute', for example "
39
+ "'pipeline:entities', naming a Resolver in pipeline.py."
40
+ )
41
+
42
+ module_name, _, attribute = target.partition(":")
43
+
44
+ # Lets a script in the working directory be named directly.
45
+ if str(Path.cwd()) not in sys.path:
46
+ sys.path.insert(0, str(Path.cwd()))
47
+
48
+ try:
49
+ module = importlib.import_module(module_name)
50
+ except ImportError as exc:
51
+ raise SystemExit(f"Could not import '{module_name}': {exc}") from exc
52
+
53
+ try:
54
+ obj: Any = getattr(module, attribute)
55
+ except AttributeError as exc:
56
+ raise SystemExit(f"'{module_name}' has no attribute '{attribute}'") from exc
57
+
58
+ if callable(obj):
59
+ obj = obj()
60
+
61
+ if not isinstance(obj, Resolver):
62
+ raise SystemExit(
63
+ f"'{target}' is a {type(obj).__name__}, not a Resolver. Review needs the "
64
+ "resolver whose clusters you want to look at."
65
+ )
66
+
67
+ return obj
68
+
69
+
70
+ def _redirect_logging(log_file: str) -> None:
71
+ """Send all logging to a file, so it doesn't scribble over the UI."""
72
+ root = logging.getLogger()
73
+ for handler in root.handlers[:]:
74
+ root.removeHandler(handler)
75
+
76
+ handler = logging.FileHandler(log_file, mode="w")
77
+ handler.setFormatter(
78
+ logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
79
+ )
80
+ root.addHandler(handler)
81
+ root.setLevel(logging.INFO)
82
+
83
+
84
+ def _build_parser() -> argparse.ArgumentParser:
85
+ parser = argparse.ArgumentParser(
86
+ prog="matchlab",
87
+ description="Build, run and evaluate entity resolution pipelines.",
88
+ )
89
+ commands = parser.add_subparsers(dest="command", required=True)
90
+
91
+ commands.add_parser("version", help="Show the installed matchlab version.")
92
+
93
+ review = commands.add_parser(
94
+ "review",
95
+ help="Review a resolver's clusters interactively.",
96
+ description=(
97
+ "Open the cluster reviewer. TARGET names a Resolver as "
98
+ "'module:attribute', such as 'pipeline:companies' for a resolver assigned "
99
+ "to `companies` in pipeline.py, or a function returning one, if building "
100
+ "the plan needs a connection you'd rather open on demand. "
101
+ "With --store, TARGET is instead the label of a resolver output published "
102
+ "in that store. Then no Python is imported. The values shown come from the "
103
+ "extracts cached when the sources were collected, so no warehouse "
104
+ "connection is needed. "
105
+ "Name several targets to review their merged clusters, so one session's "
106
+ "judgements score all of them against the same records."
107
+ ),
108
+ )
109
+ review.add_argument(
110
+ "targets",
111
+ metavar="TARGET",
112
+ nargs="+",
113
+ help="module:attribute, or a resolver output's label when --store is given",
114
+ )
115
+ review.add_argument(
116
+ "-n", "--samples", type=int, default=5, help="Clusters to queue (default: 5)."
117
+ )
118
+ review.add_argument(
119
+ "-t", "--tag", help="Tag every judgement from this session, for later scoring."
120
+ )
121
+ review.add_argument(
122
+ "--seed",
123
+ type=int,
124
+ help="Fix which clusters are drawn, so someone else can review the same ones.",
125
+ )
126
+ review.add_argument(
127
+ "-s",
128
+ "--store",
129
+ help="DuckDB store to read from and write judgements to "
130
+ "(default: the shared store in your cache directory).",
131
+ )
132
+ review.add_argument(
133
+ "--log", dest="log_file", help="Send log output to this file instead."
134
+ )
135
+ return parser
136
+
137
+
138
+ def main(argv: list[str] | None = None) -> None:
139
+ """Run the `matchlab` command."""
140
+ args = _build_parser().parse_args(argv)
141
+
142
+ if args.command == "version":
143
+ from importlib.metadata import version # noqa: PLC0415
144
+
145
+ print(version("matchlab"))
146
+ return
147
+
148
+ if args.log_file:
149
+ _redirect_logging(args.log_file)
150
+
151
+ from matchlab.adapters import DuckDBAdapter # noqa: PLC0415
152
+ from matchlab.eval import review # noqa: PLC0415
153
+
154
+ adapter = DuckDBAdapter(args.store) if args.store else None
155
+
156
+ # With a store, a bare word is a label on a stored resolver output, so it needs no
157
+ # plan.
158
+ targets: list[Any] = []
159
+ for target in args.targets:
160
+ if adapter is not None and ":" not in target:
161
+ known = adapter.labels()
162
+ if target not in known:
163
+ raise SystemExit(
164
+ f"No resolver output is labelled '{target}' in {args.store}. "
165
+ f"Known labels: {', '.join(known) or 'none'}."
166
+ )
167
+ targets.append(target)
168
+ else:
169
+ targets.append(_load_target(target))
170
+
171
+ review(
172
+ targets if len(targets) > 1 else targets[0],
173
+ n=args.samples,
174
+ adapter=adapter,
175
+ tag=args.tag,
176
+ seed=args.seed,
177
+ )
@@ -0,0 +1 @@
1
+ """Common functions, tools and objects used throughout matchlab."""
@@ -0,0 +1,254 @@
1
+ """Dataframe types, conversion, column naming and SQL reading for matchlab."""
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterator
5
+ from enum import StrEnum
6
+ from typing import (
7
+ Any,
8
+ Literal,
9
+ TypeAlias,
10
+ overload,
11
+ )
12
+
13
+ import polars as pl
14
+ from adbc_driver_manager.dbapi import Connection as AdbcConnection
15
+ from pandas import DataFrame as PandasDataFrame
16
+ from polars import DataFrame as PolarsDataFrame
17
+ from pyarrow import Table as ArrowTable
18
+ from sqlalchemy.engine import Engine
19
+
20
+ from matchlab.core.sql import SQLQuery
21
+
22
+
23
+ class DataFrameType(StrEnum):
24
+ """Enumeration of dataframe types to return from query."""
25
+
26
+ PANDAS = "pandas"
27
+ POLARS = "polars"
28
+ ARROW = "arrow"
29
+
30
+
31
+ DataFrameClass: TypeAlias = ArrowTable | PandasDataFrame | PolarsDataFrame
32
+
33
+ # A source's name prefixes every column it contributes (`crn` gives `crn_company`),
34
+ # and those land in cleaning SQL that sqlglot parses. `crn-x_company` would parse as
35
+ # subtraction and `crn.x_company` as table.column, so the name has to be usable at the
36
+ # start of an unquoted identifier. Reserved words are fine — the name is only ever a
37
+ # prefix, never a bare identifier.
38
+ _IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
39
+
40
+
41
+ def validate_col_prefix(prefix: str) -> None:
42
+ """Ensure string is safe to use as a prefix in a dataframe column."""
43
+ if not _IDENTIFIER.match(prefix):
44
+ raise ValueError(
45
+ f"'{prefix}' can't prefix a column name. It must start with a letter or"
46
+ "underscore and contain only letters, digits and underscores."
47
+ )
48
+
49
+
50
+ def qualify(source: str, field: str = "") -> str:
51
+ """The column name a source's field carries once qualified.
52
+
53
+ Args:
54
+ source: The source's name.
55
+ field: The field to qualify. Omit it for the bare prefix, which is what
56
+ `pl.all().name.prefix()` takes.
57
+
58
+ Returns:
59
+ The qualified column name.
60
+ """
61
+ return f"{source}_{field}"
62
+
63
+
64
+ @overload
65
+ def to_dataframe(
66
+ data: PolarsDataFrame, return_type: Literal[DataFrameType.POLARS]
67
+ ) -> PolarsDataFrame: ...
68
+
69
+
70
+ @overload
71
+ def to_dataframe(
72
+ data: PolarsDataFrame, return_type: Literal[DataFrameType.PANDAS]
73
+ ) -> PandasDataFrame: ...
74
+
75
+
76
+ @overload
77
+ def to_dataframe(
78
+ data: PolarsDataFrame, return_type: Literal[DataFrameType.ARROW]
79
+ ) -> ArrowTable: ...
80
+
81
+
82
+ def to_dataframe(
83
+ data: PolarsDataFrame, return_type: DataFrameType = DataFrameType.POLARS
84
+ ) -> DataFrameClass:
85
+ """Convert a Polars dataframe to the requested dataframe type.
86
+
87
+ Args:
88
+ data: The frame to convert.
89
+ return_type: The dataframe type to return. One of "arrow", "pandas",
90
+ or "polars".
91
+
92
+ Returns:
93
+ The data in the requested format.
94
+
95
+ Raises:
96
+ ValueError: If `return_type` isn't a member of `DataFrameType`.
97
+ """
98
+ match return_type:
99
+ case DataFrameType.POLARS:
100
+ return data
101
+ case DataFrameType.PANDAS:
102
+ return data.to_pandas()
103
+ case DataFrameType.ARROW:
104
+ return data.to_arrow()
105
+ case _:
106
+ raise ValueError(f"Return type {return_type} is invalid")
107
+
108
+
109
+ @overload
110
+ def sql_to_df(
111
+ stmt: SQLQuery,
112
+ connection: Engine | AdbcConnection,
113
+ return_type: Literal[DataFrameType.POLARS],
114
+ *,
115
+ return_batches: Literal[False] = False,
116
+ batch_size: int | None = None,
117
+ rename: dict[str, str] | Callable | None = None,
118
+ schema_overrides: dict[str, pl.DataType] | None = None,
119
+ execute_options: dict[str, Any] | None = None,
120
+ ) -> PolarsDataFrame: ...
121
+
122
+
123
+ @overload
124
+ def sql_to_df(
125
+ stmt: SQLQuery,
126
+ connection: Engine | AdbcConnection,
127
+ return_type: Literal[DataFrameType.PANDAS],
128
+ *,
129
+ return_batches: Literal[False] = False,
130
+ batch_size: int | None = None,
131
+ rename: dict[str, str] | Callable | None = None,
132
+ schema_overrides: dict[str, pl.DataType] | None = None,
133
+ execute_options: dict[str, Any] | None = None,
134
+ ) -> PandasDataFrame: ...
135
+
136
+
137
+ @overload
138
+ def sql_to_df(
139
+ stmt: SQLQuery,
140
+ connection: Engine | AdbcConnection,
141
+ return_type: Literal[DataFrameType.ARROW],
142
+ *,
143
+ return_batches: Literal[False] = False,
144
+ batch_size: int | None = None,
145
+ rename: dict[str, str] | Callable | None = None,
146
+ schema_overrides: dict[str, pl.DataType] | None = None,
147
+ execute_options: dict[str, Any] | None = None,
148
+ ) -> ArrowTable: ...
149
+
150
+
151
+ @overload
152
+ def sql_to_df(
153
+ stmt: SQLQuery,
154
+ connection: Engine | AdbcConnection,
155
+ return_type: Literal[DataFrameType.POLARS],
156
+ *,
157
+ return_batches: Literal[True],
158
+ batch_size: int | None = None,
159
+ rename: dict[str, str] | Callable | None = None,
160
+ schema_overrides: dict[str, pl.DataType] | None = None,
161
+ execute_options: dict[str, Any] | None = None,
162
+ ) -> Iterator[PolarsDataFrame]: ...
163
+
164
+
165
+ @overload
166
+ def sql_to_df(
167
+ stmt: SQLQuery,
168
+ connection: Engine | AdbcConnection,
169
+ return_type: Literal[DataFrameType.PANDAS],
170
+ *,
171
+ return_batches: Literal[True],
172
+ batch_size: int | None = None,
173
+ rename: dict[str, str] | Callable | None = None,
174
+ schema_overrides: dict[str, pl.DataType] | None = None,
175
+ execute_options: dict[str, Any] | None = None,
176
+ ) -> Iterator[PandasDataFrame]: ...
177
+
178
+
179
+ @overload
180
+ def sql_to_df(
181
+ stmt: SQLQuery,
182
+ connection: Engine | AdbcConnection,
183
+ return_type: Literal[DataFrameType.ARROW],
184
+ *,
185
+ return_batches: Literal[True],
186
+ batch_size: int | None = None,
187
+ rename: dict[str, str] | Callable | None = None,
188
+ schema_overrides: dict[str, pl.DataType] | None = None,
189
+ execute_options: dict[str, Any] | None = None,
190
+ ) -> Iterator[ArrowTable]: ...
191
+
192
+
193
+ def sql_to_df(
194
+ stmt: SQLQuery,
195
+ connection: Engine | AdbcConnection,
196
+ return_type: DataFrameType = DataFrameType.PANDAS,
197
+ *,
198
+ return_batches: bool = False,
199
+ batch_size: int | None = None,
200
+ rename: dict[str, str] | Callable | None = None,
201
+ schema_overrides: dict[str, pl.DataType] | None = None,
202
+ execute_options: dict[str, Any] | None = None,
203
+ ) -> DataFrameClass | Iterator[DataFrameClass]:
204
+ """Execute a SQL string with Polars, returning the requested dataframe type.
205
+
206
+ Args:
207
+ stmt: A SQL string to execute.
208
+ connection: A SQLAlchemy Engine or ADBC connection.
209
+ return_type: The dataframe type to return. One of "arrow", "pandas",
210
+ or "polars".
211
+ return_batches: If True, return an iterator that yields each batch
212
+ separately. If False, return a single dataframe with all results.
213
+ batch_size: The size of each batch, when processing in batches.
214
+ rename: A dictionary mapping old column names to new column names, or
215
+ a callable that takes a dataframe and returns a dataframe with
216
+ renamed columns.
217
+ schema_overrides: A dictionary mapping column names to dtypes.
218
+ execute_options: Passed through to the underlying query execution
219
+ method as kwargs.
220
+
221
+ Returns:
222
+ If return_batches is False: A dataframe of the query results in the specified
223
+ format.
224
+ If return_batches is True: An iterator of dataframes in the specified format.
225
+
226
+ Raises:
227
+ ValueError: If `batch_size` and `return_batches` aren't set together.
228
+ """
229
+ if not batch_size and return_batches:
230
+ raise ValueError("A batch size must be specified if return_batches is True")
231
+
232
+ if batch_size and not return_batches:
233
+ raise ValueError("Cannot set a batch size if return_batches is False")
234
+
235
+ def _to_format(results: PolarsDataFrame) -> DataFrameClass:
236
+ """Rename the results' columns, then convert to the specified format."""
237
+ if rename:
238
+ results = results.rename(rename)
239
+
240
+ return to_dataframe(results, return_type)
241
+
242
+ res = pl.read_database(
243
+ query=stmt,
244
+ connection=connection,
245
+ iter_batches=bool(batch_size),
246
+ batch_size=batch_size,
247
+ schema_overrides=schema_overrides,
248
+ execute_options=execute_options,
249
+ )
250
+
251
+ if return_batches:
252
+ return (_to_format(batch) for batch in res)
253
+
254
+ return _to_format(res)
matchlab/core/dsu.py ADDED
@@ -0,0 +1,62 @@
1
+ """Functions to transform data between tabular and graph structures."""
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Hashable
5
+ from typing import Generic, TypeVar
6
+
7
+ T = TypeVar("T", bound=Hashable)
8
+
9
+
10
+ class DisjointSet(Generic[T]):
11
+ """Disjoint set forest with "path compression" and "union by rank" heuristics.
12
+
13
+ This follows implementation from Cormen, Thomas H., et al. Introduction to
14
+ algorithms. MIT press, 2022
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ """Initialise the disjoint set."""
19
+ self.parent: dict[T, T] = {}
20
+ self.rank: dict[T, int] = {}
21
+
22
+ def _make_set(self, x: T) -> None:
23
+ """Create a new set with a single element x."""
24
+ self.parent[x] = x
25
+ self.rank[x] = 0
26
+
27
+ def add(self, x: T) -> None:
28
+ """Add a new element to the disjoint set."""
29
+ if x not in self.parent:
30
+ self._make_set(x)
31
+
32
+ def union(self, x: T, y: T) -> None:
33
+ """Merge the sets containing elements x and y."""
34
+ self._link(self._find(x), self._find(y))
35
+
36
+ def _link(self, x: T, y: T) -> None:
37
+ """Merge the sets containing elements x and y."""
38
+ if self.rank[x] > self.rank[y]:
39
+ self.parent[y] = x
40
+ else:
41
+ self.parent[x] = y
42
+ if self.rank[x] == self.rank[y]:
43
+ self.rank[y] += 1
44
+
45
+ def _find(self, x: T) -> T:
46
+ """Return the representative element of the set containing x."""
47
+ if x not in self.parent:
48
+ self._make_set(x)
49
+ return x
50
+
51
+ if x != self.parent[x]:
52
+ self.parent[x] = self._find(self.parent[x])
53
+
54
+ return self.parent[x]
55
+
56
+ def get_components(self) -> list[set[T]]:
57
+ """Return the connected components of the disjoint set."""
58
+ components = defaultdict(set)
59
+ for x in self.parent:
60
+ root = self._find(x)
61
+ components[root].add(x)
62
+ return list(components.values())
@@ -0,0 +1,40 @@
1
+ """Exceptions raised by matchlab."""
2
+
3
+ from pyarrow import Schema
4
+
5
+
6
+ class MatchlabError(Exception):
7
+ """An error occurred in matchlab."""
8
+
9
+ def __init__(self, message: str | None = None) -> None:
10
+ """Initialise the error, defaulting the message to the class docstring."""
11
+ super().__init__(message or self.__doc__)
12
+
13
+
14
+ class SchemaMismatch(MatchlabError):
15
+ """An Arrow table did not have the expected schema."""
16
+
17
+ def __init__(self, expected: Schema, actual: Schema) -> None:
18
+ """Initialise with the schemas that failed to match."""
19
+ super().__init__(f"Schema mismatch. Expected:\n{expected}\nGot:\n{actual}")
20
+
21
+
22
+ class ExtractTransformError(MatchlabError):
23
+ """A source's extract/transform SQL is not valid."""
24
+
25
+
26
+ class SourceTableError(MatchlabError):
27
+ """A source's table could not be read from its location."""
28
+
29
+
30
+ class StepNotFound(MatchlabError):
31
+ """A step could not be found."""
32
+
33
+ def __init__(self, message: str | None = None, name: str | None = None) -> None:
34
+ """Initialise with an explicit message, or a name to build one from."""
35
+ if message is None:
36
+ message = (
37
+ f"Step {name} not found." if name is not None else "Step not found."
38
+ )
39
+ super().__init__(message)
40
+ self.name = name
matchlab/core/hash.py ADDED
@@ -0,0 +1,141 @@
1
+ """Utilities for hashing data and creating unique identifiers."""
2
+
3
+ import hashlib
4
+ from enum import StrEnum
5
+
6
+ import polars as pl
7
+ import polars.expr as plx
8
+ import polars_hash as plh
9
+ import pyarrow as pa
10
+
11
+ HASH_FUNC = hashlib.sha256
12
+
13
+
14
+ class HashMethod(StrEnum):
15
+ """Supported hash methods for row hashing."""
16
+
17
+ XXH3_128 = "xxh3_128"
18
+ SHA256 = "sha256"
19
+
20
+
21
+ def _process_column_for_hashing(column_name: str, schema_type: pl.DataType) -> plx.Expr:
22
+ r"""Normalise `column_name` to a string column safe to hash.
23
+
24
+ Nulls become the sentinel `"\x00"`, so a null hashes differently from an empty
25
+ value. Binary columns hex-encode, structs JSON-encode and lists join on `,`,
26
+ before falling back to a plain string cast.
27
+ """
28
+ if isinstance(schema_type, pl.Binary):
29
+ return (
30
+ pl.col(column_name).fill_null("\x00").bin.encode("hex").alias(column_name)
31
+ )
32
+ elif isinstance(schema_type, pl.Struct):
33
+ return (
34
+ pl.col(column_name)
35
+ .struct.json_encode()
36
+ .fill_null("\x00")
37
+ .alias(column_name)
38
+ )
39
+ elif isinstance(schema_type, pl.List):
40
+ return pl.col(column_name).list.join(",").fill_null("\x00").alias(column_name)
41
+ else:
42
+ return pl.col(column_name).cast(pl.Utf8).fill_null("\x00").alias(column_name)
43
+
44
+
45
+ def hash_rows(
46
+ df: pl.DataFrame, columns: list[str], method: HashMethod = HashMethod.XXH3_128
47
+ ) -> pl.Series:
48
+ """Hash each row of `df` over `columns`.
49
+
50
+ Each row's hash covers both column names and values, joined with the record and
51
+ unit separator symbols (`␞`, `␟`), so a column's name is part of what gets hashed
52
+ alongside its value.
53
+ """
54
+ expr_list = [
55
+ _process_column_for_hashing(column, df.schema[column]) for column in columns
56
+ ]
57
+ df_processed = df.with_columns(expr_list)
58
+
59
+ record_separator = "␞"
60
+ unit_separator = "␟"
61
+
62
+ str_concatenation: list[pl.Expr] = []
63
+ for c in columns:
64
+ str_concatenation.extend(
65
+ [
66
+ pl.lit(c), # column name
67
+ pl.lit(unit_separator),
68
+ pl.col(c), # column value
69
+ pl.lit(record_separator),
70
+ ]
71
+ )
72
+
73
+ if method == HashMethod.XXH3_128:
74
+ row_hashes = df_processed.select(
75
+ plh.concat_str(str_concatenation).nchash.xxh3_128().alias("row_hash")
76
+ )
77
+ return row_hashes["row_hash"]
78
+ elif method == HashMethod.SHA256:
79
+ row_hashes = df_processed.select(
80
+ plh.concat_str(str_concatenation)
81
+ .chash.sha2_256()
82
+ .str.decode("hex")
83
+ .alias("row_hash")
84
+ )
85
+ return row_hashes["row_hash"]
86
+ else:
87
+ raise ValueError(f"Unsupported hash method: {method}")
88
+
89
+
90
+ def hash_arrow_table(
91
+ table: pa.Table,
92
+ method: HashMethod = HashMethod.XXH3_128,
93
+ as_sorted_list: list[str] | None = None,
94
+ ) -> bytes:
95
+ """Content-address `table` with a hash invariant to row and column order.
96
+
97
+ Pass `as_sorted_list` (2 or more column names, e.g. `["left_id", "right_id"]`) to
98
+ hash those columns as a sorted set instead of individually, so `(1, 2)` and
99
+ `(2, 1)` hash the same. This replaces the named columns with one `sorted_list`
100
+ column.
101
+
102
+ Combining `as_sorted_list` with a nullable column can null the whole row's hash
103
+ input, because Polars' `concat_list` returns null when any input is null.
104
+
105
+ Raises:
106
+ ValueError: If `as_sorted_list` names fewer than two columns, or a column
107
+ `table` doesn't have.
108
+ """
109
+ df = pl.from_arrow(table)
110
+
111
+ if df.height == 0:
112
+ return b"empty_table_hash"
113
+
114
+ if as_sorted_list:
115
+ if len(as_sorted_list) < 2:
116
+ raise ValueError(
117
+ "Lists passed to as_sorted_list must contain at least 2 column names"
118
+ )
119
+
120
+ missing_cols = [col for col in as_sorted_list if col not in df.columns]
121
+ if missing_cols:
122
+ raise ValueError(f"Columns not found in dataframe: {missing_cols}")
123
+
124
+ df = df.with_columns(
125
+ pl.concat_list(as_sorted_list).list.sort().alias("sorted_list")
126
+ ).drop(as_sorted_list)
127
+
128
+ columns: list[str] = sorted(df.columns)
129
+ df = df.select(columns)
130
+
131
+ # Explode list columns so each element sorts and hashes on its own. That's what
132
+ # makes the hash invariant to the order of elements within a list field.
133
+ for column in columns:
134
+ if isinstance(df.schema[column], pl.List):
135
+ df = df.explode(column, empty_as_null=True)
136
+
137
+ df = df.sort(by=columns)
138
+ row_hashes = hash_rows(df=df, columns=columns, method=method)
139
+ all_hashes: bytes = b"".join(row_hashes.sort().to_list())
140
+
141
+ return HASH_FUNC(all_hashes).digest()
matchlab/core/kinds.py ADDED
@@ -0,0 +1,12 @@
1
+ """The kinds of step a plan is made of."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class StepKind(StrEnum):
7
+ """Enumeration of the kinds of step a plan is made of."""
8
+
9
+ SOURCE = "source"
10
+ TRANSFORM = "transform"
11
+ MODEL = "model"
12
+ RESOLVER = "resolver"