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.
- matchlab/__init__.py +99 -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 +143 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +263 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/core/versioning.py +62 -0
- matchlab/document.py +363 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +413 -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 +249 -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 +42 -0
- matchlab/models/dedupers/naive.py +75 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +45 -0
- matchlab/models/linkers/deterministic.py +224 -0
- matchlab/models/linkers/splinklinker.py +293 -0
- matchlab/models/linkers/weighteddeterministic.py +130 -0
- matchlab/models/models.py +247 -0
- matchlab/progress.py +465 -0
- matchlab/recordstep.py +190 -0
- matchlab/resolvers/__init__.py +9 -0
- matchlab/resolvers/base.py +69 -0
- matchlab/resolvers/components.py +61 -0
- matchlab/resolvers/resolvers.py +451 -0
- matchlab/resources.py +153 -0
- matchlab/sources/__init__.py +31 -0
- matchlab/sources/base.py +54 -0
- matchlab/sources/dataframe.py +50 -0
- matchlab/sources/relational.py +97 -0
- matchlab/sources/sources.py +438 -0
- matchlab/specs.py +128 -0
- matchlab/steps.py +536 -0
- matchlab/stores/__init__.py +24 -0
- matchlab/stores/base.py +444 -0
- matchlab/stores/default.py +25 -0
- matchlab/stores/duckdb.py +661 -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 +518 -0
- matchlab/testkit/matchers.py +226 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +330 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +74 -0
- matchlab/transformers/clean.py +56 -0
- matchlab/transformers/explode.py +70 -0
- matchlab/transformers/group.py +61 -0
- matchlab/transformers/select.py +35 -0
- matchlab/transformers/transform.py +173 -0
- matchlab-0.1.0.dist-info/METADATA +96 -0
- matchlab-0.1.0.dist-info/RECORD +78 -0
- matchlab-0.1.0.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|