matchlab 0.1.0__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 (78) hide show
  1. matchlab/__init__.py +99 -0
  2. matchlab/cli.py +177 -0
  3. matchlab/core/__init__.py +1 -0
  4. matchlab/core/dataframes.py +254 -0
  5. matchlab/core/dsu.py +62 -0
  6. matchlab/core/exceptions.py +40 -0
  7. matchlab/core/hash.py +143 -0
  8. matchlab/core/kinds.py +12 -0
  9. matchlab/core/logging.py +263 -0
  10. matchlab/core/resolver_output.py +173 -0
  11. matchlab/core/schemas.py +82 -0
  12. matchlab/core/sql.py +12 -0
  13. matchlab/core/versioning.py +62 -0
  14. matchlab/document.py +363 -0
  15. matchlab/eval/__init__.py +11 -0
  16. matchlab/eval/judgements.py +41 -0
  17. matchlab/eval/metrics.py +244 -0
  18. matchlab/eval/samples.py +413 -0
  19. matchlab/eval/tui/__init__.py +66 -0
  20. matchlab/eval/tui/app.py +418 -0
  21. matchlab/eval/tui/modals.py +84 -0
  22. matchlab/eval/tui/styles.tcss +184 -0
  23. matchlab/eval/tui/widgets/__init__.py +1 -0
  24. matchlab/eval/tui/widgets/assignment.py +72 -0
  25. matchlab/eval/tui/widgets/styling.py +75 -0
  26. matchlab/eval/tui/widgets/table.py +285 -0
  27. matchlab/lineage.py +249 -0
  28. matchlab/models/__init__.py +10 -0
  29. matchlab/models/comparison.py +67 -0
  30. matchlab/models/dedupers/__init__.py +5 -0
  31. matchlab/models/dedupers/base.py +42 -0
  32. matchlab/models/dedupers/naive.py +75 -0
  33. matchlab/models/linkers/__init__.py +9 -0
  34. matchlab/models/linkers/base.py +45 -0
  35. matchlab/models/linkers/deterministic.py +224 -0
  36. matchlab/models/linkers/splinklinker.py +293 -0
  37. matchlab/models/linkers/weighteddeterministic.py +130 -0
  38. matchlab/models/models.py +247 -0
  39. matchlab/progress.py +465 -0
  40. matchlab/recordstep.py +190 -0
  41. matchlab/resolvers/__init__.py +9 -0
  42. matchlab/resolvers/base.py +69 -0
  43. matchlab/resolvers/components.py +61 -0
  44. matchlab/resolvers/resolvers.py +451 -0
  45. matchlab/resources.py +153 -0
  46. matchlab/sources/__init__.py +31 -0
  47. matchlab/sources/base.py +54 -0
  48. matchlab/sources/dataframe.py +50 -0
  49. matchlab/sources/relational.py +97 -0
  50. matchlab/sources/sources.py +438 -0
  51. matchlab/specs.py +128 -0
  52. matchlab/steps.py +536 -0
  53. matchlab/stores/__init__.py +24 -0
  54. matchlab/stores/base.py +444 -0
  55. matchlab/stores/default.py +25 -0
  56. matchlab/stores/duckdb.py +661 -0
  57. matchlab/testkit/__init__.py +104 -0
  58. matchlab/testkit/_generate.py +240 -0
  59. matchlab/testkit/compare.py +178 -0
  60. matchlab/testkit/entities.py +290 -0
  61. matchlab/testkit/features.py +146 -0
  62. matchlab/testkit/linked.py +518 -0
  63. matchlab/testkit/matchers.py +226 -0
  64. matchlab/testkit/models.py +165 -0
  65. matchlab/testkit/sources.py +330 -0
  66. matchlab/transformers/__init__.py +24 -0
  67. matchlab/transformers/base.py +74 -0
  68. matchlab/transformers/clean.py +56 -0
  69. matchlab/transformers/explode.py +70 -0
  70. matchlab/transformers/group.py +61 -0
  71. matchlab/transformers/select.py +35 -0
  72. matchlab/transformers/transform.py +173 -0
  73. matchlab-0.1.0.dist-info/METADATA +96 -0
  74. matchlab-0.1.0.dist-info/RECORD +78 -0
  75. matchlab-0.1.0.dist-info/WHEEL +5 -0
  76. matchlab-0.1.0.dist-info/entry_points.txt +2 -0
  77. matchlab-0.1.0.dist-info/licenses/LICENSE +21 -0
  78. matchlab-0.1.0.dist-info/top_level.txt +1 -0
matchlab/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """The matchlab plan API.
2
+
3
+ Build a plan from a source, chain verbs, then `collect()`:
4
+
5
+ ```python
6
+ import matchlab as mb
7
+
8
+ crn = mb.read_database("crn", sql=..., client=engine, key_field="pk")
9
+ deduped = crn.clean(...).dedupe(model_class=mb.NaiveDeduper, ...).resolve()
10
+ lookup = deduped.collect().get_lookup()
11
+ ```
12
+
13
+ Nothing runs until `collect()`, and collecting again only does the work whose plan
14
+ or inputs changed.
15
+
16
+ Everything needed to write a plan is re-exported here, including built-in methodologies.
17
+ Evaluation stays behind `matchlab.eval`, since a plan runs without it.
18
+ """
19
+
20
+ from importlib.metadata import version
21
+
22
+ from matchlab.document import PlanDocument, dump, load
23
+ from matchlab.models import Model, add_model_class
24
+ from matchlab.models.dedupers import NaiveDeduper
25
+ from matchlab.models.dedupers.base import Deduper
26
+ from matchlab.models.linkers import (
27
+ DeterministicLinker,
28
+ SplinkLinker,
29
+ WeightedDeterministicLinker,
30
+ )
31
+ from matchlab.models.linkers.base import Linker
32
+ from matchlab.recordstep import RecordStep
33
+ from matchlab.resolvers import Components, Resolver
34
+ from matchlab.resolvers.base import ResolverMethod
35
+ from matchlab.resolvers.resolvers import add_resolver_class
36
+ from matchlab.resources import FromResources, Resource
37
+ from matchlab.sources import (
38
+ DataFrame,
39
+ Location,
40
+ RelationalDB,
41
+ Source,
42
+ add_location_class,
43
+ read_database,
44
+ read_dataframe,
45
+ )
46
+ from matchlab.steps import Step
47
+ from matchlab.stores import DuckDBStore
48
+ from matchlab.stores.default import default_store, set_default_store
49
+ from matchlab.transformers import (
50
+ Clean,
51
+ Explode,
52
+ Group,
53
+ Select,
54
+ Transform,
55
+ Transformer,
56
+ add_transformer_class,
57
+ )
58
+
59
+ __version__ = version("matchlab")
60
+
61
+ __all__ = (
62
+ "Clean",
63
+ "Components",
64
+ "DataFrame",
65
+ "Deduper",
66
+ "DeterministicLinker",
67
+ "DuckDBStore",
68
+ "Explode",
69
+ "FromResources",
70
+ "Group",
71
+ "Linker",
72
+ "Location",
73
+ "Model",
74
+ "NaiveDeduper",
75
+ "PlanDocument",
76
+ "RecordStep",
77
+ "RelationalDB",
78
+ "Resolver",
79
+ "ResolverMethod",
80
+ "Resource",
81
+ "Select",
82
+ "Source",
83
+ "SplinkLinker",
84
+ "Step",
85
+ "Transform",
86
+ "Transformer",
87
+ "WeightedDeterministicLinker",
88
+ "__version__",
89
+ "add_location_class",
90
+ "add_model_class",
91
+ "add_resolver_class",
92
+ "add_transformer_class",
93
+ "default_store",
94
+ "dump",
95
+ "load",
96
+ "read_database",
97
+ "read_dataframe",
98
+ "set_default_store",
99
+ )
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.eval import review # noqa: PLC0415
152
+ from matchlab.stores import DuckDBStore # noqa: PLC0415
153
+
154
+ store = DuckDBStore(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 store is not None and ":" not in target:
161
+ known = store.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
+ store=store,
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 SourceTableError(MatchlabError):
23
+ """A source's table could not be read from its location."""
24
+
25
+
26
+ class StepNotFound(MatchlabError):
27
+ """A step could not be found."""
28
+
29
+ def __init__(self, message: str | None = None, name: str | None = None) -> None:
30
+ """Initialise with an explicit message, or a name to build one from."""
31
+ if message is None:
32
+ message = (
33
+ f"Step {name} not found." if name is not None else "Step not found."
34
+ )
35
+ super().__init__(message)
36
+ self.name = name
37
+
38
+
39
+ class ResourceError(ValueError):
40
+ """A resource could not be named, resolved, or bound unambiguously."""