structmatch 0.1.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: structmatch
3
+ Version: 0.1.0
4
+ Summary: Deep structural matching, diffing, and pattern matching for Python
5
+ Author: Teja
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
10
+ Requires-Dist: hypothesis; extra == "dev"
11
+ Requires-Dist: pytest-cov; extra == "dev"
@@ -0,0 +1,94 @@
1
+ # structmatch
2
+
3
+ Deep structural matching, diffing, and pattern matching for Python. Zero dependencies.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from structmatch import eq, diff, match, ANY, GT, LT, BETWEEN
15
+
16
+ # Deep equality
17
+ eq({"a": [1, 2]}, {"a": [1, 2]}) # True
18
+
19
+ # Approximate matching
20
+ eq(3.14159, 3.14160, tolerance=0.001) # True
21
+
22
+ # Order-independent lists
23
+ eq([1, 2, 3], [3, 2, 1], ignore_order=True) # True
24
+
25
+ # Ignore specific keys
26
+ eq({"a": 1, "id": 999}, {"a": 1, "id": 1}, ignore_keys=["id"]) # True
27
+
28
+ # Case-insensitive strings
29
+ eq("Hello", "hello", case_sensitive=False) # True
30
+
31
+ # Type coercion (int == float)
32
+ eq(1, 1.0, type_coerce=True) # True
33
+
34
+ # Deep diff
35
+ result = diff({"a": 1, "b": 2}, {"a": 1, "b": 20, "c": 3})
36
+ print(result.added) # {"c": 3}
37
+ print(result.changed) # {"b": (2, 20)}
38
+
39
+ # Pattern matching
40
+ match({"status": 200, "count": 5}, {
41
+ "status": GT(199),
42
+ "count": BETWEEN(1, 10),
43
+ }) # True
44
+
45
+ match(42, ANY) # True
46
+ match("hello@example.com", REGEX(r"@")) # True
47
+ ```
48
+
49
+ ## Schema Validation
50
+
51
+ ```python
52
+ from structmatch import Schema
53
+
54
+ UserSchema = Schema({
55
+ "name": str,
56
+ "age": int,
57
+ "tags": [str],
58
+ "metadata": {str: object},
59
+ })
60
+
61
+ UserSchema.validate({"name": "Alice", "age": 30, "tags": ["admin"], "metadata": {"key": "val"}}) # True
62
+ ```
63
+
64
+ ## Custom Comparators
65
+
66
+ ```python
67
+ from structmatch import eq, Comparator
68
+
69
+ class DateTimeWithin(Comparator):
70
+ def __init__(self, seconds):
71
+ self.seconds = seconds
72
+ def matches(self, a, b):
73
+ return abs((a - b).total_seconds()) < self.seconds
74
+
75
+ eq({"created": now}, {"created": later}, comparators=[DateTimeWithin(5)])
76
+ ```
77
+
78
+ ## All Comparators
79
+
80
+ | Comparator | Matches |
81
+ |---|---|
82
+ | `ANY` | Anything |
83
+ | `TYPE(t)` | Any value of type `t` |
84
+ | `GT(n)` | `value > n` |
85
+ | `LT(n)` | `value < n` |
86
+ | `GE(n)` | `value >= n` |
87
+ | `LE(n)` | `value <= n` |
88
+ | `BETWEEN(a, b)` | `a <= value <= b` |
89
+ | `REGEX(pattern)` | String matches regex |
90
+ | `NOT(comp)` | Negates another comparator |
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "structmatch"
3
+ version = "0.1.0"
4
+ description = "Deep structural matching, diffing, and pattern matching for Python"
5
+ license = {text = "MIT"}
6
+ requires-python = ">=3.10"
7
+ authors = [{name = "Teja"}]
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=7.0", "hypothesis", "pytest-cov"]
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68.0"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """structmatch — Deep structural matching, diffing, and pattern matching for Python."""
2
+
3
+ from .core import eq, diff, match
4
+ from .comparators import (
5
+ Comparator,
6
+ ANY,
7
+ TYPE,
8
+ GT,
9
+ LT,
10
+ GE,
11
+ LE,
12
+ BETWEEN,
13
+ REGEX,
14
+ NOT,
15
+ )
16
+ from .diff import DiffResult
17
+ from .schema import Schema, SchemaError
18
+ from .options import MatchOptions
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "eq",
24
+ "diff",
25
+ "match",
26
+ "ANY",
27
+ "TYPE",
28
+ "GT",
29
+ "LT",
30
+ "GE",
31
+ "LE",
32
+ "BETWEEN",
33
+ "REGEX",
34
+ "NOT",
35
+ "Comparator",
36
+ "DiffResult",
37
+ "Schema",
38
+ "SchemaError",
39
+ "MatchOptions",
40
+ ]
@@ -0,0 +1,106 @@
1
+ """Pattern-matching comparators."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class Comparator(ABC):
9
+ """Base class for custom comparators."""
10
+
11
+ @abstractmethod
12
+ def matches(self, a, b) -> bool:
13
+ ...
14
+
15
+ def __call__(self, a, b) -> bool:
16
+ return self.matches(a, b)
17
+
18
+
19
+ class ANY(Comparator):
20
+ """Matches anything."""
21
+
22
+ def matches(self, a, b) -> bool:
23
+ return True
24
+
25
+
26
+ class TYPE(Comparator):
27
+ """Matches any value of a given type."""
28
+
29
+ def __init__(self, expected_type):
30
+ self.expected_type = expected_type
31
+
32
+ def matches(self, a, b) -> bool:
33
+ return isinstance(b, self.expected_type)
34
+
35
+
36
+ class GT(Comparator):
37
+ """Matches if b > value."""
38
+
39
+ def __init__(self, value):
40
+ self.value = value
41
+
42
+ def matches(self, a, b) -> bool:
43
+ return b > self.value
44
+
45
+
46
+ class LT(Comparator):
47
+ """Matches if b < value."""
48
+
49
+ def __init__(self, value):
50
+ self.value = value
51
+
52
+ def matches(self, a, b) -> bool:
53
+ return b < self.value
54
+
55
+
56
+ class GE(Comparator):
57
+ """Matches if b >= value."""
58
+
59
+ def __init__(self, value):
60
+ self.value = value
61
+
62
+ def matches(self, a, b) -> bool:
63
+ return b >= self.value
64
+
65
+
66
+ class LE(Comparator):
67
+ """Matches if b <= value."""
68
+
69
+ def __init__(self, value):
70
+ self.value = value
71
+
72
+ def matches(self, a, b) -> bool:
73
+ return b <= self.value
74
+
75
+
76
+ class BETWEEN(Comparator):
77
+ """Matches if low <= b <= high."""
78
+
79
+ def __init__(self, low, high):
80
+ self.low = min(low, high)
81
+ self.high = max(low, high)
82
+
83
+ def matches(self, a, b) -> bool:
84
+ return self.low <= b <= self.high
85
+
86
+
87
+ class REGEX(Comparator):
88
+ """Matches if b (string) matches the regex pattern."""
89
+
90
+ def __init__(self, pattern: str):
91
+ self.pattern = re.compile(pattern)
92
+
93
+ def matches(self, a, b) -> bool:
94
+ if not isinstance(b, str):
95
+ return False
96
+ return bool(self.pattern.search(b))
97
+
98
+
99
+ class NOT(Comparator):
100
+ """Negates another comparator."""
101
+
102
+ def __init__(self, comparator: Comparator):
103
+ self.comparator = comparator
104
+
105
+ def matches(self, a, b) -> bool:
106
+ return not self.comparator.matches(a, b)
@@ -0,0 +1,177 @@
1
+ """Core functions: eq(), diff(), match()."""
2
+
3
+ from __future__ import annotations
4
+ from .options import MatchOptions
5
+ from .diff import DiffResult, diff as _diff
6
+ from .utils import (
7
+ _is_numeric,
8
+ _within_tolerance,
9
+ _compare_strings,
10
+ _filter_keys,
11
+ _is_dataclass_like,
12
+ _get_fields,
13
+ _is_comparator,
14
+ _as_multiset,
15
+ _hashable,
16
+ )
17
+ from .comparators import Comparator
18
+
19
+
20
+ def _deep_eq(va, vb, opts: MatchOptions) -> bool:
21
+ """Recursive deep equality."""
22
+ # Check custom comparators first
23
+ for comp in opts.comparators:
24
+ if comp.matches(va, vb):
25
+ return True
26
+
27
+ # Handle type coercion: int/float interop
28
+ if opts.type_coerce and _is_numeric(va, vb) and _within_tolerance(va, vb, opts.tolerance):
29
+ return True
30
+
31
+ # Type mismatch (but handle dataclass/NamedTuple equality)
32
+ type_a = type(va)
33
+ type_b = type(vb)
34
+ if type_a != type_b:
35
+ if opts.type_coerce and _is_numeric(va, vb) and _within_tolerance(va, vb, opts.tolerance):
36
+ return True
37
+ return False
38
+
39
+ # None
40
+ if va is None:
41
+ return vb is None
42
+
43
+ # Booleans
44
+ if isinstance(va, bool):
45
+ return va == vb
46
+
47
+ # Strings
48
+ if isinstance(va, str):
49
+ return _compare_strings(va, vb, opts.case_sensitive)
50
+
51
+ # Numbers
52
+ if _is_numeric(va, vb):
53
+ return _within_tolerance(va, vb, opts.tolerance)
54
+
55
+ # Dicts
56
+ if isinstance(va, dict):
57
+ if len(va) != len(vb):
58
+ return False
59
+ va_f = _filter_keys(va, opts.ignore_keys)
60
+ vb_f = _filter_keys(vb, opts.ignore_keys)
61
+ if len(va_f) != len(vb_f):
62
+ return False
63
+ if set(va_f) != set(vb_f):
64
+ return False
65
+ for k in va_f:
66
+ if not _deep_eq(va_f[k], vb_f[k], opts):
67
+ return False
68
+ return True
69
+
70
+ # Lists / tuples
71
+ if isinstance(va, (list, tuple)):
72
+ if type(va) != type(vb):
73
+ return False
74
+ if len(va) != len(vb):
75
+ return False
76
+ if opts.ignore_order and isinstance(va, list):
77
+ return _as_multiset(va) == _as_multiset(vb)
78
+ return all(_deep_eq(a, b, opts) for a, b in zip(va, vb))
79
+
80
+ # Sets
81
+ if isinstance(va, set):
82
+ return va == vb
83
+
84
+ # Dataclasses / NamedTuples / objects with __dict__
85
+ if _is_dataclass_like(va):
86
+ fa = _get_fields(va)
87
+ fb = _get_fields(vb)
88
+ if fa is not None and fb is not None:
89
+ return _deep_eq(fa, fb, opts)
90
+
91
+ # Objects with __dict__ (but not dataclasses)
92
+ if not isinstance(va, (type, bool, int, float, str, list, tuple, set, dict)):
93
+ fa = _get_fields(va)
94
+ fb = _get_fields(vb)
95
+ if fa is not None and fb is not None and type(va) == type(vb):
96
+ return _deep_eq(fa, fb, opts)
97
+
98
+ # Fallback to ==
99
+ return va == vb
100
+
101
+
102
+ def eq(a, b, *, tolerance: float = 0.0, ignore_order: bool = False,
103
+ ignore_keys: list[str] | None = None, case_sensitive: bool = True,
104
+ type_coerce: bool = False, comparators: list[Comparator] | None = None) -> bool:
105
+ """Deep structural equality comparison."""
106
+ opts = MatchOptions(
107
+ tolerance=tolerance,
108
+ ignore_order=ignore_order,
109
+ ignore_keys=ignore_keys,
110
+ case_sensitive=case_sensitive,
111
+ type_coerce=type_coerce,
112
+ comparators=comparators,
113
+ )
114
+ return _deep_eq(a, b, opts)
115
+
116
+
117
+ def diff(a, b, **opts) -> DiffResult:
118
+ """Compute a deep diff between two structures."""
119
+ return _diff(a, b, **opts)
120
+
121
+
122
+ def _deep_match(value, pattern, opts: MatchOptions) -> bool:
123
+ """Check if value matches a pattern (which may contain comparators)."""
124
+ # Pattern is a comparator (check FIRST, before any type checks)
125
+ if _is_comparator(pattern):
126
+ if isinstance(pattern, type):
127
+ pattern = pattern()
128
+ return pattern.matches(value, value)
129
+
130
+ # None value
131
+ if value is None:
132
+ if pattern is None or pattern is type(None):
133
+ return True
134
+ if isinstance(pattern, type):
135
+ return False
136
+ return False
137
+
138
+ # Pattern is a type
139
+ if isinstance(pattern, type):
140
+ if pattern is type(None):
141
+ return value is None
142
+ if pattern in (int, float):
143
+ return _is_numeric(value, value) and isinstance(value, pattern)
144
+ return isinstance(value, pattern)
145
+
146
+ # Both dicts
147
+ if isinstance(pattern, dict) and isinstance(value, dict):
148
+ value_f = _filter_keys(value, opts.ignore_keys)
149
+ for key, sub_pattern in pattern.items():
150
+ if key not in value_f:
151
+ return False
152
+ if not _deep_match(value_f[key], sub_pattern, opts):
153
+ return False
154
+ return True
155
+
156
+ # Both lists/tuples
157
+ if isinstance(pattern, (list, tuple)) and isinstance(value, (list, tuple)):
158
+ if len(pattern) != len(value):
159
+ return False
160
+ return all(_deep_match(v, p, opts) for v, p in zip(value, pattern))
161
+
162
+ # String pattern with case sensitivity
163
+ if isinstance(pattern, str) and isinstance(value, str):
164
+ return _compare_strings(pattern, value, opts.case_sensitive)
165
+
166
+ # Numeric with tolerance
167
+ if _is_numeric(pattern, value) or _is_numeric(value, pattern):
168
+ return _within_tolerance(pattern, value, opts.tolerance)
169
+
170
+ # Literal match
171
+ return pattern == value
172
+
173
+
174
+ def match(obj, pattern, **opts) -> bool:
175
+ """Pattern matching: check if obj matches the given pattern."""
176
+ options = MatchOptions(**opts)
177
+ return _deep_match(obj, pattern, options)
@@ -0,0 +1,203 @@
1
+ """Deep diff engine."""
2
+
3
+ from __future__ import annotations
4
+ from .options import MatchOptions
5
+ from .utils import (
6
+ _is_numeric,
7
+ _within_tolerance,
8
+ _compare_strings,
9
+ _filter_keys,
10
+ _is_dataclass_like,
11
+ _get_fields,
12
+ )
13
+
14
+
15
+ class DiffResult:
16
+ """Result of a deep diff between two structures."""
17
+
18
+ __slots__ = ("added", "removed", "changed", "type_changes", "path_changes")
19
+
20
+ def __init__(
21
+ self,
22
+ added: dict | None = None,
23
+ removed: dict | None = None,
24
+ changed: dict | None = None,
25
+ type_changes: dict | None = None,
26
+ path_changes: list | None = None,
27
+ ):
28
+ self.added = added if added is not None else {}
29
+ self.removed = removed if removed is not None else {}
30
+ self.changed = changed if changed is not None else {}
31
+ self.type_changes = type_changes if type_changes is not None else {}
32
+ self.path_changes = path_changes if path_changes is not None else []
33
+
34
+ def has_changes(self) -> bool:
35
+ return bool(self.added or self.removed or self.changed or self.type_changes or self.path_changes)
36
+
37
+ def __bool__(self) -> bool:
38
+ return self.has_changes()
39
+
40
+ def __repr__(self) -> str:
41
+ parts = []
42
+ if self.added:
43
+ parts.append(f"added={self.added}")
44
+ if self.removed:
45
+ parts.append(f"removed={self.removed}")
46
+ if self.changed:
47
+ parts.append(f"changed={self.changed}")
48
+ if self.type_changes:
49
+ parts.append(f"type_changes={self.type_changes}")
50
+ if self.path_changes:
51
+ parts.append(f"path_changes={self.path_changes}")
52
+ return f"DiffResult({', '.join(parts)})"
53
+
54
+ def __eq__(self, other):
55
+ if not isinstance(other, DiffResult):
56
+ return NotImplemented
57
+ return (
58
+ self.added == other.added
59
+ and self.removed == other.removed
60
+ and self.changed == other.changed
61
+ and self.type_changes == other.type_changes
62
+ )
63
+
64
+
65
+ def _diff_dicts(a: dict, b: dict, opts: MatchOptions, path: str = "") -> DiffResult:
66
+ added = {}
67
+ removed = {}
68
+ changed = {}
69
+ type_changes = {}
70
+ path_changes = []
71
+
72
+ a_filtered = _filter_keys(a, opts.ignore_keys)
73
+ b_filtered = _filter_keys(b, opts.ignore_keys)
74
+
75
+ all_keys = set(a_filtered) | set(b_filtered)
76
+ for key in all_keys:
77
+ key_path = f"{path}.{key}" if path else key
78
+ if key not in a_filtered:
79
+ added[key] = b_filtered[key]
80
+ elif key not in b_filtered:
81
+ removed[key] = a_filtered[key]
82
+ else:
83
+ va = a_filtered[key]
84
+ vb = b_filtered[key]
85
+ child = _diff_values(va, vb, opts, key_path)
86
+ if child is None:
87
+ continue
88
+ if isinstance(child, dict):
89
+ if "sub_diff" in child:
90
+ sub = child["sub_diff"]
91
+ # Merge sub_diff into current
92
+ added.update({f"{key}.{k}" if k in added else k: v for k, v in sub.added.items()})
93
+ removed.update({f"{key}.{k}" if k in removed else k: v for k, v in sub.removed.items()})
94
+ changed.update({f"{key}.{k}" if k in changed else k: v for k, v in sub.changed.items()})
95
+ type_changes.update({f"{key}.{k}" if k in type_changes else k: v for k, v in sub.type_changes.items()})
96
+ path_changes.extend(sub.path_changes)
97
+ elif "type_change" in child:
98
+ type_changes[key] = child["type_change"]
99
+ elif "set_diff" in child:
100
+ changed[key] = (va, vb)
101
+ elif isinstance(child, tuple):
102
+ changed[key] = child
103
+
104
+ return DiffResult(added=added, removed=removed, changed=changed, type_changes=type_changes, path_changes=path_changes)
105
+
106
+
107
+ def _diff_sequences(a, b, opts: MatchOptions, path: str = "") -> DiffResult:
108
+ if opts.ignore_order:
109
+ # If same elements (multiset), no changes
110
+ from .utils import _as_multiset
111
+ if _as_multiset(a) == _as_multiset(b):
112
+ return DiffResult()
113
+
114
+ path_changes = []
115
+ max_len = max(len(a), len(b))
116
+ for i in range(max_len):
117
+ idx_path = f"{path}[{i}]"
118
+ if i >= len(a):
119
+ path_changes.append({"path": idx_path, "change": "added", "value": b[i]})
120
+ elif i >= len(b):
121
+ path_changes.append({"path": idx_path, "change": "removed", "value": a[i]})
122
+ else:
123
+ child = _diff_values(a[i], b[i], opts, idx_path)
124
+ if child is not None:
125
+ if isinstance(child, dict) and "type_change" in child:
126
+ path_changes.append({"path": idx_path, "change": "type_change", "from": child["type_change"][0], "to": child["type_change"][1]})
127
+ elif isinstance(child, tuple):
128
+ path_changes.append({"path": idx_path, "change": "changed", "from": child[0], "to": child[1]})
129
+ elif isinstance(child, dict) and "sub_diff" in child:
130
+ path_changes.append({"path": idx_path, "change": "sub_diff", "details": child["sub_diff"]})
131
+ else:
132
+ path_changes.append({"path": idx_path, "change": "changed", "from": a[i], "to": b[i]})
133
+
134
+ return DiffResult(path_changes=path_changes)
135
+
136
+
137
+ def _diff_values(va, vb, opts: MatchOptions, path: str = ""):
138
+ """Returns None if equal, a tuple (old, new) if changed, or dict with type_change."""
139
+ type_a = type(va)
140
+ type_b = type(vb)
141
+
142
+ if type_a != type_b:
143
+ if opts.type_coerce and _is_numeric(va, vb) and _within_tolerance(va, vb, opts.tolerance):
144
+ return None
145
+ return {"type_change": (type_a, type_b)}
146
+
147
+ if isinstance(va, dict):
148
+ sub = _diff_dicts(va, vb, opts, path)
149
+ if sub.has_changes():
150
+ return {"sub_diff": sub}
151
+ return None
152
+
153
+ if isinstance(va, str):
154
+ if not _compare_strings(va, vb, opts.case_sensitive):
155
+ return (va, vb)
156
+ return None
157
+
158
+ if _is_numeric(va, vb):
159
+ if _within_tolerance(va, vb, opts.tolerance):
160
+ return None
161
+ return (va, vb)
162
+
163
+ if isinstance(va, (list, tuple)):
164
+ sub = _diff_sequences(va, vb, opts, path)
165
+ if sub.has_changes():
166
+ return {"sub_diff": sub}
167
+ return None
168
+
169
+ if isinstance(va, set):
170
+ extra_a = va - vb
171
+ extra_b = vb - va
172
+ if extra_a or extra_b:
173
+ return {"set_diff": {"removed": extra_a, "added": extra_b}}
174
+ return None
175
+
176
+ if _is_dataclass_like(va):
177
+ fa = _get_fields(va)
178
+ fb = _get_fields(vb)
179
+ return _diff_dicts(fa, fb, opts, path)
180
+
181
+ if va == vb:
182
+ return None
183
+ return (va, vb)
184
+
185
+
186
+ def diff(a, b, **opts) -> DiffResult:
187
+ """Compute a deep diff between two structures."""
188
+ options = MatchOptions(**opts)
189
+ result = _diff_values(a, b, options)
190
+ if result is None:
191
+ return DiffResult()
192
+ if isinstance(result, dict):
193
+ if "sub_diff" in result:
194
+ return result["sub_diff"]
195
+ if "type_change" in result:
196
+ return DiffResult(type_changes={"root": result["type_change"]})
197
+ if "set_diff" in result:
198
+ return DiffResult(
199
+ added={"root_set_added": result["set_diff"]["added"]},
200
+ removed={"root_set_removed": result["set_diff"]["removed"]},
201
+ )
202
+ return DiffResult(changed={"root": (a, b)})
203
+ return DiffResult(changed={"root": result})
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class MatchOptions:
5
+ """Options controlling comparison behavior."""
6
+
7
+ __slots__ = (
8
+ "tolerance",
9
+ "ignore_order",
10
+ "ignore_keys",
11
+ "case_sensitive",
12
+ "type_coerce",
13
+ "comparators",
14
+ )
15
+
16
+ def __init__(
17
+ self,
18
+ tolerance: float = 0.0,
19
+ ignore_order: bool = False,
20
+ ignore_keys: set[str] | None = None,
21
+ case_sensitive: bool = True,
22
+ type_coerce: bool = False,
23
+ comparators: list | None = None,
24
+ ):
25
+ self.tolerance = tolerance
26
+ self.ignore_order = ignore_order
27
+ self.ignore_keys = frozenset(ignore_keys) if ignore_keys else frozenset()
28
+ self.case_sensitive = case_sensitive
29
+ self.type_coerce = type_coerce
30
+ self.comparators = comparators or []
31
+
32
+ def update(self, **kwargs) -> MatchOptions:
33
+ """Return a new MatchOptions with updated fields."""
34
+ d = {
35
+ "tolerance": self.tolerance,
36
+ "ignore_order": self.ignore_order,
37
+ "ignore_keys": set(self.ignore_keys) if self.ignore_keys else None,
38
+ "case_sensitive": self.case_sensitive,
39
+ "type_coerce": self.type_coerce,
40
+ "comparators": list(self.comparators),
41
+ }
42
+ d.update(kwargs)
43
+ return MatchOptions(**d)