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
@@ -0,0 +1,190 @@
1
+ """The logger and console everything in matchlab reports through.
2
+
3
+ Two objects, one each for the two ways output leaves the library:
4
+
5
+ * `logger` — the `matchlab` logger, wrapped so any record can carry a prefix saying
6
+ which part of a plan produced it. Like any library logger it has no handler of its
7
+ own, and is silent until the application configures logging.
8
+ * `console` — the Rich console. `matchlab.progress` draws the collection tree on it,
9
+ and tests swap it for a quiet one.
10
+
11
+ Both are module attributes rather than values passed around, so `matchlab.progress`
12
+ reads them through the module (`mlog.console`) instead of importing them directly.
13
+ That indirection is what lets a test patch one and have the library pick it up.
14
+
15
+ **A prefix is usually ambient, not passed.** The thing worth quoting is a step's
16
+ position in the plan being collected, and the code that knows a message worth logging
17
+ is rarely the code that knows the position — a `Linker` counting matches is three
18
+ layers below the walk that numbered its step. `collect` therefore publishes the
19
+ position in `prefix`, a `ContextVar`, and every record emitted while that step runs
20
+ picks it up. Call sites stay ignorant, which is what keeps positions a property of the
21
+ walk rather than of the step (see `matchlab.steps.Step.__init__`).
22
+
23
+ The `prefix=` keyword remains for callers with something to say outside a collection,
24
+ where there is no position to quote. It is a fallback, not an override: an ambient
25
+ position wins, so records read consistently within the run that printed the tree.
26
+ """
27
+
28
+ import logging
29
+ import sys
30
+ from collections.abc import Generator
31
+ from contextlib import contextmanager
32
+ from contextvars import ContextVar
33
+ from typing import IO, Any, Final
34
+
35
+ from rich.console import Console
36
+ from rich.file_proxy import FileProxy
37
+
38
+ prefix: Final[ContextVar[str | None]] = ContextVar("matchlab_prefix", default=None)
39
+ """The prefix records carry right now, or `None` outside any collection.
40
+
41
+ Set by `matchlab.progress.Progress` for the duration of each step, so that anything
42
+ logged while that step runs is tied to the plan the collection drew. Being a
43
+ `ContextVar` is what makes it restore correctly when a collection runs inside another:
44
+ the inner one holds the outer one's token and gives it back on the way out.
45
+
46
+ A handler or filter that wants to know which step is running can read it directly.
47
+ Setting it is `Progress`'s business — a record of your own takes `prefix=` instead.
48
+ """
49
+
50
+
51
+ class PrefixedLoggerAdapter(logging.LoggerAdapter):
52
+ """A logger adapter that tags records with the plan position they came from.
53
+
54
+ The prefix is normally ambient, read from `prefix`. A `prefix=` keyword on the call
55
+ supplies one for records emitted outside a collection.
56
+ """
57
+
58
+ def process(
59
+ self,
60
+ msg: Any, # noqa: ANN401
61
+ kwargs: dict[str, Any],
62
+ ) -> tuple[Any, dict[str, Any]]:
63
+ """Add the ambient or explicit prefix to `msg`, if either is set."""
64
+ # Popped either way: `prefix` is ours, and `Logger.log` would reject it.
65
+ explicit = kwargs.pop("prefix", None)
66
+ # Ambient first: inside a collection the position is the prefix that matches
67
+ # the tree, and it is the one a reader can look up. A caller's own idea of
68
+ # what it is only applies where there is no position to quote.
69
+ tag = prefix.get() or explicit
70
+
71
+ if tag:
72
+ msg = f"[{tag}] {msg}"
73
+
74
+ return msg, kwargs
75
+
76
+
77
+ logger: Final[PrefixedLoggerAdapter] = PrefixedLoggerAdapter(
78
+ logging.getLogger("matchlab"), {}
79
+ )
80
+ """Logger for matchlab.
81
+
82
+ Used for all logging in the matchlab library.
83
+
84
+ Records logged while a collection is running are prefixed with the position of the step
85
+ that produced them, which is what ties a record to the plan `collect` drew. Nothing at
86
+ the call site arranges that; see `prefix`.
87
+
88
+ Examples:
89
+ ```python
90
+ logger.info(f"Round {round_num}: Found {len(matches):,} matches")
91
+ ```
92
+ """
93
+
94
+ console: Final[Console] = Console()
95
+ """Console for matchlab.
96
+
97
+ Where `matchlab.progress` draws a collection's plan, and where any CLI utility writes.
98
+ """
99
+
100
+
101
+ @contextmanager
102
+ def through_console() -> Generator[None]:
103
+ """Route handlers already writing to this terminal through the console instead.
104
+
105
+ Rich redirects `sys.stdout` and `sys.stderr` while a live display is running, so a
106
+ bare `print` lands above the frame rather than through it. Logging misses out:
107
+ `logging.StreamHandler` reads `sys.stderr` **once**, when it is constructed, and
108
+ `logging.basicConfig()` constructs one at import time — long before there is a
109
+ frame to avoid. That handler then writes straight past Rich for the rest of the
110
+ process.
111
+
112
+ That is the ordinary case, not an exotic one. Rather than asking every caller to
113
+ work around it, `matchlab.progress` closes the gap itself: for as long as it owns
114
+ the terminal, it points any handler bound to that terminal at the same proxy Rich
115
+ installs, and puts it back afterwards. Handlers writing anywhere else — a file, a
116
+ socket, another stream — are left alone, having nothing to collide with.
117
+
118
+ It restores the original streams on the way out, however the block ends. It's
119
+ reentrant only in the sense that a handler already proxied is skipped, so a nested
120
+ display can't double-wrap one.
121
+
122
+ It does nothing in a notebook. The problem it solves is a cursor collision, and a
123
+ notebook has no cursor: Rich draws a live display into an `ipywidgets.Output` that
124
+ redraws itself in isolation, so nothing can smear it. Routing records through the
125
+ console there would replace each one with its own `display()` call — a separate
126
+ HTML block, boxed and spaced apart. Left alone, the stream they already write to
127
+ renders them as ordinary contiguous text instead.
128
+ """
129
+ if console.is_jupyter:
130
+ yield
131
+ return
132
+
133
+ # Read before Rich swaps them: afterwards `sys.stdout` is a proxy, and a handler
134
+ # holding the real stream would no longer look like it points at the terminal.
135
+ terminal = {sys.stdout, sys.stderr, console.file}
136
+ patched: list[tuple[logging.StreamHandler, IO[str]]] = []
137
+
138
+ target: logging.Logger | None = logging.getLogger("matchlab")
139
+ while target is not None:
140
+ for handler in target.handlers:
141
+ stream = getattr(handler, "stream", None)
142
+ if (
143
+ isinstance(handler, logging.StreamHandler)
144
+ and not isinstance(stream, FileProxy)
145
+ and any(stream is candidate for candidate in terminal)
146
+ ):
147
+ patched.append((handler, stream))
148
+ handler.setStream(FileProxy(console, stream))
149
+ target = target.parent if target.propagate else None
150
+
151
+ try:
152
+ yield
153
+ finally:
154
+ for handler, stream in patched:
155
+ handler.setStream(stream)
156
+
157
+
158
+ class ConsoleHandler(logging.Handler):
159
+ """A logging handler that writes through matchlab's own console.
160
+
161
+ Sharing the console is what lets logging coexist with the live tree
162
+ `matchlab.progress` draws: Rich prints through the live region, so records land
163
+ above the frame instead of over it. A handler holding a raw stream — which is what
164
+ `logging.basicConfig()` gives you, bound at the moment it was called — writes
165
+ straight past Rich and smears whatever is being redrawn.
166
+
167
+ Deliberately plainer than `rich.logging.RichHandler`, which lays records out in a
168
+ table and would wrap a logged plan inside its narrow message column. What matchlab
169
+ logs is mostly trees, and a tree that wraps is not a tree. Records are written at
170
+ the console's full width, unstyled, and formatted however the application asked.
171
+
172
+ You do not need this to run a collection at a terminal: `through_console` already
173
+ borrows a handler bound to one for as long as a live tree is up. This is for saying
174
+ so up front, and for the handler that outlives any one collection.
175
+
176
+ Examples:
177
+ ```python
178
+ logging.basicConfig(level=logging.INFO, handlers=[ConsoleHandler()])
179
+ ```
180
+ """
181
+
182
+ def emit(self, record: logging.LogRecord) -> None:
183
+ """Write `record` to the console."""
184
+ try:
185
+ # `markup=False` matters: a drawn tree is full of `[2]`, which Rich would
186
+ # otherwise try to parse as a style tag. `highlight=False` likewise — its
187
+ # guesses at numbers and brackets colour the tree into confetti.
188
+ console.print(self.format(record), markup=False, highlight=False)
189
+ except Exception: # noqa: BLE001 - a handler must never raise into the caller
190
+ self.handleError(record)
@@ -0,0 +1,173 @@
1
+ """Each resolver materialises its complete, merge-forward output once.
2
+
3
+ Every resolver, when it runs, computes that output and stores it. Everything
4
+ downstream (`entities`, `lookup_key`, models querying through it) just reads the
5
+ stored table.
6
+
7
+ This module holds the pure functions that produce that output:
8
+
9
+ * `leaf_id` / `root_id` — content-addressed cluster IDs. A leaf is a hash of a
10
+ record's content. That is the correct anchor for evaluation, not merely a
11
+ reproducibility trick — see `leaf_id` for the full reasoning.
12
+ * `materialise_resolver_output` — turns a resolver's clusters and upstream output
13
+ into the complete result table. See its own docstring for the shape of each.
14
+
15
+ **Merge-forward** means a leaf grouped upstream but untouched by this resolver
16
+ inherits its upstream cluster rather than collapsing to a singleton.
17
+ `materialise_resolver_output` implements this by giving every *untouched* reachable ID
18
+ its own component ("fall-through"), so the upstream grouping survives.
19
+
20
+ This module assumes a resolver's inputs share a consistent lineage, so each
21
+ reachable leaf maps to exactly one upstream ID. DAG construction enforces that, so
22
+ nothing here needs to reconcile a leaf that maps to more than one.
23
+ """
24
+
25
+ from collections.abc import Iterable
26
+
27
+ import polars as pl
28
+ import polars_hash # noqa: F401 - registers the `nchash` expression namespace
29
+
30
+ from matchlab.core.schemas import SCHEMA_RESOLVER_OUTPUT
31
+
32
+ # Every reachable query-space ID, mapped to a source row and its leaf. This is the
33
+ # shape `materialise_resolver_output` expects for its `upstream` argument.
34
+ UPSTREAM_COLUMNS = ("id", "source", "key", "leaf")
35
+
36
+
37
+ def leaf_id(row_hash: pl.Expr) -> pl.Expr:
38
+ """Map a column of row content hashes to stable 64-bit leaf cluster IDs.
39
+
40
+ A record's identity is a hash of its **content**, not of its key. This is the
41
+ load-bearing decision, and it is deliberate, not an accident of implementation.
42
+ The reason is evaluation.
43
+
44
+ A judgement is a person or a model saying "looking at *this*, these records are the
45
+ same entity." The only input to that decision is the content shown — a name, a
46
+ postcode. The key plays no part. The judge never sees it. A judgement must
47
+ therefore be anchored to the content it was actually made against:
48
+
49
+ * If you anchor to the **key**, a judgement outlives a content change. Say a
50
+ judgement was made when the evidence read "acme / london = acme / london". If
51
+ that evidence later becomes "acme / manchester", the judgement still stands —
52
+ attributed to evidence the judge never saw. It looks inexplicable later, or
53
+ quietly validates a match nobody made.
54
+ * If you anchor to the **content** instead (this module's choice), the leaf ID
55
+ changes whenever the content does, so the old judgement stops applying. That is
56
+ correct. Nobody has judged the new content, so there should be no judgement
57
+ about it. Judgements decay exactly when their evidence does.
58
+
59
+ The same logic explains why identical rows share a leaf. To any judge they are
60
+ indistinguishable, so there is no decision to make. Differing keys don't separate
61
+ them, because a key is not evidence.
62
+
63
+ **What gets hashed must match what gets shown.** If a column feeds the hash but
64
+ the sampler doesn't display it, a judgement decays for a reason the judge never
65
+ saw. If a column appears in the sampler but doesn't feed the hash, a judgement
66
+ survives a change the judge did see, which is wrong.
67
+
68
+ The two stay in sync today because both read the same definition. `leaf_id`
69
+ hashes every non-key column (see `Source._read_warehouse`), and `get_samples`
70
+ displays every non-key column. Selecting a column in the extract is what makes it
71
+ both hashed and shown — there is no second list to keep in step by hand (see
72
+ `Source`'s "the extract is the whole declaration").
73
+
74
+ Content-addressing also makes runs reproducible and IDs stable across re-collect,
75
+ but that is a consequence, not the reason.
76
+
77
+ This is vectorised deliberately. Calling a Python function once per row, as
78
+ `map_elements` would, dominates collection time.
79
+ """
80
+ return row_hash.bin.slice(0, 8).bin.reinterpret(dtype=pl.UInt64, endianness="big")
81
+
82
+
83
+ def root_id(leaves: pl.Expr) -> pl.Expr:
84
+ """Deterministic 64-bit root cluster ID for a column of leaf lists.
85
+
86
+ This is invariant to leaf order (callers sort) and to the arbitrary component
87
+ label, so two runs that produce the same clustering produce the same root IDs.
88
+
89
+ This is vectorised for the same reason as `leaf_id`. Calling this once per
90
+ cluster, in Python, would mean as many calls as there are entities.
91
+ """
92
+ # `polars_hash` registers the `nchash` namespace on polars expressions at import.
93
+ joined = leaves.cast(pl.List(pl.Utf8)).list.join(",")
94
+ return joined.nchash.xxh3_64().cast(pl.UInt64)
95
+
96
+
97
+ def root_id_of(leaves: Iterable[int]) -> int:
98
+ """`root_id` for a single leaf set. For tests and one-off checks, not for loops."""
99
+ leaves = sorted(leaves)
100
+ if not leaves:
101
+ raise ValueError("A cluster must contain at least one leaf")
102
+ frame = pl.DataFrame({"leaves": [leaves]}).with_columns(
103
+ pl.col("leaves").list.eval(pl.element().cast(pl.UInt64))
104
+ )
105
+ return frame.select(root_id(pl.col("leaves")).alias("root"))["root"][0]
106
+
107
+
108
+ def materialise_resolver_output(
109
+ clusters: pl.DataFrame,
110
+ upstream: pl.DataFrame,
111
+ ) -> pl.DataFrame:
112
+ """Build a resolver's complete, merge-forward output.
113
+
114
+ Args:
115
+ clusters: `SCHEMA_CLUSTERS` `(parent_id, child_id)` from the resolver's
116
+ methodology. `child_id`s are query-space IDs the models formed edges over.
117
+ IDs not present here are untouched by this resolver.
118
+ upstream: The union of the resolver's input queries as `(id, source, key,
119
+ leaf)` rows, covering *every* leaf reachable by the resolver, whether or
120
+ not an edge formed over it. `id` is the query-space ID the model
121
+ referenced.
122
+
123
+ Returns:
124
+ One row per reachable source record, shaped as `SCHEMA_RESOLVER_OUTPUT`
125
+ `(root, leaf, key, source)`, with `root` the cluster it resolves to. Complete
126
+ and merge-forward.
127
+ """
128
+ missing = set(UPSTREAM_COLUMNS) - set(upstream.columns)
129
+ if missing:
130
+ raise ValueError(f"upstream is missing columns: {sorted(missing)}")
131
+
132
+ upstream = upstream.select(
133
+ pl.col("id").cast(pl.UInt64),
134
+ pl.col("source").cast(pl.Utf8),
135
+ pl.col("key").cast(pl.Utf8),
136
+ pl.col("leaf").cast(pl.UInt64),
137
+ )
138
+
139
+ if upstream.height == 0:
140
+ return pl.from_arrow(SCHEMA_RESOLVER_OUTPUT.empty_table())
141
+
142
+ child_to_parent = clusters.select(
143
+ pl.col("child_id").cast(pl.UInt64),
144
+ pl.col("parent_id").cast(pl.UInt64),
145
+ )
146
+
147
+ # Component label per reachable row:
148
+ # * touched IDs (in a cluster) -> "c{parent_id}" (this resolver's new cluster)
149
+ # * untouched IDs -> "s{id}" (fall-through: keep upstream)
150
+ # Prefixes keep the two integer spaces from colliding.
151
+ labelled = upstream.join(
152
+ child_to_parent, left_on="id", right_on="child_id", how="left"
153
+ ).with_columns(
154
+ pl.when(pl.col("parent_id").is_not_null())
155
+ .then("c" + pl.col("parent_id").cast(pl.Utf8))
156
+ .otherwise("s" + pl.col("id").cast(pl.Utf8))
157
+ .alias("_component")
158
+ )
159
+
160
+ # Content-addressed root per component, derived from its full leaf set.
161
+ component_roots = (
162
+ labelled.group_by("_component")
163
+ .agg(pl.col("leaf").unique().sort().alias("_leaves"))
164
+ .with_columns(root_id(pl.col("_leaves")).alias("root"))
165
+ .select("_component", "root")
166
+ )
167
+
168
+ return (
169
+ labelled.join(component_roots, on="_component")
170
+ .select("root", "leaf", "key", "source")
171
+ .unique()
172
+ .sort("root", "leaf", "key")
173
+ )
@@ -0,0 +1,82 @@
1
+ """Arrow schemas for the artifacts an adapter persists.
2
+
3
+ These are the shapes that cross the boundary between a step and its storage: what a
4
+ `Model` or `Resolver` hands the adapter, and what the adapter hands back. They live here
5
+ rather than with their producers because both sides need them, and `core` is the only
6
+ layer both a producer and an adapter can depend on without inverting.
7
+
8
+ The membership rule is exactly that boundary — **the adapter persists it or returns
9
+ it**. A shape that never leaves the process belongs with the code that produces it:
10
+ `SCHEMA_CLUSTERS` is a resolver methodology's output, read by the `Resolver` that called
11
+ it and by nothing else, so it lives in `matchlab.resolvers.base`. Keeping those out is
12
+ what stops this module from becoming a catch-all for anything that happens to be an
13
+ Arrow schema.
14
+ """
15
+
16
+ from typing import Final
17
+
18
+ import pyarrow as pa
19
+ from pyarrow import Schema
20
+
21
+ from matchlab.core.exceptions import SchemaMismatch
22
+
23
+ SCHEMA_MODEL_EDGES: Final[pa.Schema] = pa.schema(
24
+ [
25
+ ("left_id", pa.uint64()),
26
+ ("right_id", pa.uint64()),
27
+ ("score", pa.float32()),
28
+ ]
29
+ )
30
+ """A deduper's or linker's output: a scored edge list."""
31
+
32
+ SCHEMA_RESOLVER_OUTPUT: Final[pa.Schema] = pa.schema(
33
+ [
34
+ ("root", pa.uint64()),
35
+ ("leaf", pa.uint64()),
36
+ ("key", pa.large_string()),
37
+ ("source", pa.large_string()),
38
+ ]
39
+ )
40
+ """A resolver's complete flat output: one row per reachable source record.
41
+
42
+ Evaluation sampling returns a row-subset of this, which is why it is named for the
43
+ resolver's output rather than for the sample.
44
+ """
45
+
46
+ SCHEMA_JUDGEMENTS: Final[pa.Schema] = pa.schema(
47
+ [
48
+ ("user_name", pa.large_string()),
49
+ ("endorsed", pa.uint64()),
50
+ ("shown", pa.uint64()),
51
+ ]
52
+ )
53
+ """Stored user judgements, as returned by `Adapter.read_eval_data`."""
54
+
55
+ SCHEMA_CLUSTER_EXPANSION: Final[pa.Schema] = pa.schema(
56
+ [
57
+ ("root", pa.uint64()),
58
+ ("leaves", pa.list_(pa.uint64())),
59
+ ]
60
+ )
61
+ """A cluster ID mapped to all its source cluster IDs.
62
+
63
+ Used alongside `SCHEMA_JUDGEMENTS` in evaluation scoring."""
64
+
65
+
66
+ def check_schema_subset(expected: Schema, actual: Schema) -> None:
67
+ """Check presence of Arrow fields, ignoring field order, extras and metadata.
68
+
69
+ Args:
70
+ expected: Schema with fields that must be present
71
+ actual: Schema to check
72
+ """
73
+ actual = pa.schema(
74
+ [
75
+ (name, actual.field(name).type)
76
+ for name in expected.names
77
+ if name in actual.names
78
+ ]
79
+ )
80
+
81
+ if not actual.equals(expected):
82
+ raise SchemaMismatch(expected=expected, actual=actual)
matchlab/core/sql.py ADDED
@@ -0,0 +1,12 @@
1
+ """SQL fragment string types shared across matchlab."""
2
+
3
+ from typing import TypeAlias
4
+
5
+ SQLExpression: TypeAlias = str
6
+ """Any SQL text `sqlglot` can parse, with no shape guaranteed beyond that."""
7
+
8
+ SQLCondition: TypeAlias = SQLExpression
9
+ """A boolean WHERE-clause predicate, qualified with `l`/`r` per `models.comparison`."""
10
+
11
+ SQLQuery: TypeAlias = SQLExpression
12
+ """A full SQL statement, such as a `SELECT`, not just a predicate."""