pyrsql 0.0.1__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 (74) hide show
  1. pyrsql/__init__.py +63 -0
  2. pyrsql/adapters/__init__.py +3 -0
  3. pyrsql/adapters/fastapi/__init__.py +15 -0
  4. pyrsql/adapters/fastapi/config.py +76 -0
  5. pyrsql/adapters/fastapi/criteria.py +56 -0
  6. pyrsql/adapters/fastapi/dependency.py +217 -0
  7. pyrsql/adapters/fastapi/errors.py +107 -0
  8. pyrsql/api.py +76 -0
  9. pyrsql/core/__init__.py +45 -0
  10. pyrsql/core/compiler.py +81 -0
  11. pyrsql/core/conversion.py +206 -0
  12. pyrsql/core/custom.py +14 -0
  13. pyrsql/core/field_policy.py +97 -0
  14. pyrsql/core/joins.py +11 -0
  15. pyrsql/core/json/__init__.py +19 -0
  16. pyrsql/core/json/options.py +31 -0
  17. pyrsql/core/json/path.py +35 -0
  18. pyrsql/core/json/query.py +39 -0
  19. pyrsql/core/json/values.py +72 -0
  20. pyrsql/core/options.py +416 -0
  21. pyrsql/core/page.py +115 -0
  22. pyrsql/core/procedure_policy.py +61 -0
  23. pyrsql/core/query.py +160 -0
  24. pyrsql/core/sort.py +153 -0
  25. pyrsql/integrations/__init__.py +8 -0
  26. pyrsql/integrations/fastapi/__init__.py +8 -0
  27. pyrsql/integrations/fastapi/sqlalchemy.py +251 -0
  28. pyrsql/orms/__init__.py +15 -0
  29. pyrsql/orms/base.py +99 -0
  30. pyrsql/orms/sqlalchemy/__init__.py +39 -0
  31. pyrsql/orms/sqlalchemy/coercion.py +57 -0
  32. pyrsql/orms/sqlalchemy/compiled.py +56 -0
  33. pyrsql/orms/sqlalchemy/compiled_page.py +40 -0
  34. pyrsql/orms/sqlalchemy/compiled_sort.py +56 -0
  35. pyrsql/orms/sqlalchemy/custom.py +25 -0
  36. pyrsql/orms/sqlalchemy/errors.py +17 -0
  37. pyrsql/orms/sqlalchemy/introspection.py +107 -0
  38. pyrsql/orms/sqlalchemy/json_path.py +351 -0
  39. pyrsql/orms/sqlalchemy/orm.py +65 -0
  40. pyrsql/orms/sqlalchemy/resolver.py +202 -0
  41. pyrsql/orms/sqlalchemy/sorter.py +173 -0
  42. pyrsql/orms/sqlalchemy/statement.py +40 -0
  43. pyrsql/orms/sqlalchemy/translator.py +560 -0
  44. pyrsql/orms/sqlalchemy/type_inference.py +27 -0
  45. pyrsql/orms/sqlalchemy/types.py +64 -0
  46. pyrsql/parsing/__init__.py +32 -0
  47. pyrsql/parsing/ast.py +61 -0
  48. pyrsql/parsing/errors.py +25 -0
  49. pyrsql/parsing/lexer.py +264 -0
  50. pyrsql/parsing/limits.py +15 -0
  51. pyrsql/parsing/operators.py +201 -0
  52. pyrsql/parsing/parser.py +292 -0
  53. pyrsql/parsing/source.py +48 -0
  54. pyrsql/parsing/tokens.py +30 -0
  55. pyrsql/py.typed +0 -0
  56. pyrsql/selector/__init__.py +30 -0
  57. pyrsql/selector/analyzer.py +58 -0
  58. pyrsql/selector/ast.py +31 -0
  59. pyrsql/selector/parser.py +130 -0
  60. pyrsql/selector/semantic.py +34 -0
  61. pyrsql/semantic/__init__.py +23 -0
  62. pyrsql/semantic/analyzer.py +116 -0
  63. pyrsql/semantic/ast.py +35 -0
  64. pyrsql/semantic/errors.py +40 -0
  65. pyrsql/sorting/__init__.py +26 -0
  66. pyrsql/sorting/analyzer.py +74 -0
  67. pyrsql/sorting/ast.py +46 -0
  68. pyrsql/sorting/errors.py +21 -0
  69. pyrsql/sorting/limits.py +15 -0
  70. pyrsql/sorting/parser.py +150 -0
  71. pyrsql/sorting/semantic.py +15 -0
  72. pyrsql-0.0.1.dist-info/METADATA +72 -0
  73. pyrsql-0.0.1.dist-info/RECORD +74 -0
  74. pyrsql-0.0.1.dist-info/WHEEL +4 -0
pyrsql/core/options.py ADDED
@@ -0,0 +1,416 @@
1
+ """Shared query options."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from types import MappingProxyType
6
+ from typing import Any, Final, TypeVar
7
+
8
+ from pyrsql.core.conversion import (
9
+ DEFAULT_VALUE_CONVERTER_REGISTRY,
10
+ FieldValueConverterSet,
11
+ ValueConverter,
12
+ ValueConverterRegistry,
13
+ )
14
+ from pyrsql.core.custom import CustomPredicateDefinition
15
+ from pyrsql.core.field_policy import FieldPolicySet
16
+ from pyrsql.core.joins import JoinHint
17
+ from pyrsql.core.json.options import JSONOptions
18
+ from pyrsql.core.procedure_policy import ProcedureAccessPolicy
19
+ from pyrsql.parsing.limits import ParseLimits
20
+ from pyrsql.parsing.operators import DEFAULT_OPERATOR_REGISTRY, OperatorRegistry
21
+ from pyrsql.sorting.limits import SortLimits
22
+
23
+ _NestedValueT = TypeVar("_NestedValueT")
24
+ _EMPTY_TUPLE: Final[tuple[str, ...]] = ()
25
+
26
+
27
+ def _normalize_mapping(
28
+ mapping: Mapping[str, _NestedValueT],
29
+ ) -> Mapping[str, _NestedValueT]:
30
+ """Normalizes a flat mapping into an immutable view."""
31
+ return MappingProxyType(dict(mapping))
32
+
33
+
34
+ def _normalize_tuple(values: tuple[str, ...]) -> tuple[str, ...]:
35
+ """Normalizes tuple-like string containers."""
36
+ return tuple(values)
37
+
38
+
39
+ def _normalize_frozenset(values: frozenset[str]) -> frozenset[str]:
40
+ """Normalizes set-like string containers."""
41
+ return frozenset(values)
42
+
43
+
44
+ def _build_field_policy(
45
+ *,
46
+ field_mapping: Mapping[str, str],
47
+ field_whitelist: frozenset[str],
48
+ field_blacklist: frozenset[str],
49
+ model_field_mapping: Mapping[type[Any], Mapping[str, str]],
50
+ model_field_whitelist: Mapping[type[Any], frozenset[str]],
51
+ model_field_blacklist: Mapping[type[Any], frozenset[str]],
52
+ ) -> FieldPolicySet:
53
+ """Builds a normalized field policy from option values."""
54
+ return FieldPolicySet(
55
+ field_mapping=field_mapping,
56
+ field_whitelist=field_whitelist,
57
+ field_blacklist=field_blacklist,
58
+ model_field_mapping=model_field_mapping,
59
+ model_field_whitelist=model_field_whitelist,
60
+ model_field_blacklist=model_field_blacklist,
61
+ )
62
+
63
+
64
+ def _build_procedure_policy(
65
+ whitelist: tuple[str, ...],
66
+ blacklist: tuple[str, ...],
67
+ ) -> ProcedureAccessPolicy:
68
+ """Builds a compiled procedure policy from raw patterns."""
69
+ return ProcedureAccessPolicy.from_patterns(whitelist, blacklist)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class QueryOptions:
74
+ """ORM-neutral configuration for parsing and compiling queries.
75
+
76
+ The options bundle parser limits, operator registries, field policies,
77
+ custom predicates, value converters, and JSON behavior in one immutable
78
+ object.
79
+ """
80
+
81
+ strict_equality: bool = False
82
+ distinct: bool = False
83
+ like_escape_character: str | None = None
84
+ field_mapping: Mapping[str, str] = field(default_factory=dict)
85
+ model_field_mapping: Mapping[type[Any], Mapping[str, str]] = field(
86
+ default_factory=dict
87
+ )
88
+ join_hints: Mapping[str, JoinHint] = field(default_factory=dict)
89
+ field_whitelist: frozenset[str] = field(default_factory=frozenset)
90
+ field_blacklist: frozenset[str] = field(default_factory=frozenset)
91
+ model_field_whitelist: Mapping[type[Any], frozenset[str]] = field(
92
+ default_factory=dict
93
+ )
94
+ model_field_blacklist: Mapping[type[Any], frozenset[str]] = field(
95
+ default_factory=dict
96
+ )
97
+ procedure_whitelist: tuple[str, ...] = ()
98
+ procedure_blacklist: tuple[str, ...] = ()
99
+ parse_limits: ParseLimits = field(default_factory=ParseLimits)
100
+ operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY
101
+ custom_predicates: Mapping[str, CustomPredicateDefinition] = field(
102
+ default_factory=dict
103
+ )
104
+ value_converter_registry: ValueConverterRegistry = (
105
+ DEFAULT_VALUE_CONVERTER_REGISTRY
106
+ )
107
+ field_value_converters: Mapping[str, ValueConverter] = field(
108
+ default_factory=dict
109
+ )
110
+ model_field_value_converters: Mapping[
111
+ type[Any], Mapping[str, ValueConverter]
112
+ ] = field(default_factory=dict)
113
+ json_options: JSONOptions = field(default_factory=JSONOptions)
114
+ _field_policy: FieldPolicySet = field(
115
+ init=False,
116
+ repr=False,
117
+ compare=False,
118
+ )
119
+ _field_converter_set: FieldValueConverterSet = field(
120
+ init=False,
121
+ repr=False,
122
+ compare=False,
123
+ )
124
+ _procedure_policy: ProcedureAccessPolicy = field(
125
+ init=False,
126
+ repr=False,
127
+ compare=False,
128
+ )
129
+
130
+ def __post_init__(self) -> None:
131
+ """Normalizes option containers into immutable representations."""
132
+ object.__setattr__(
133
+ self,
134
+ "field_mapping",
135
+ _normalize_mapping(self.field_mapping),
136
+ )
137
+ object.__setattr__(
138
+ self,
139
+ "model_field_mapping",
140
+ _normalize_nested_mapping(self.model_field_mapping),
141
+ )
142
+ object.__setattr__(
143
+ self,
144
+ "join_hints",
145
+ _normalize_mapping(self.join_hints),
146
+ )
147
+ object.__setattr__(
148
+ self,
149
+ "field_value_converters",
150
+ _normalize_mapping(self.field_value_converters),
151
+ )
152
+ object.__setattr__(
153
+ self,
154
+ "model_field_value_converters",
155
+ _normalize_nested_mapping(self.model_field_value_converters),
156
+ )
157
+ object.__setattr__(
158
+ self,
159
+ "custom_predicates",
160
+ _normalize_mapping(self.custom_predicates),
161
+ )
162
+ object.__setattr__(
163
+ self,
164
+ "field_whitelist",
165
+ _normalize_frozenset(self.field_whitelist),
166
+ )
167
+ object.__setattr__(
168
+ self,
169
+ "field_blacklist",
170
+ _normalize_frozenset(self.field_blacklist),
171
+ )
172
+ object.__setattr__(
173
+ self,
174
+ "model_field_whitelist",
175
+ _normalize_nested_sets(self.model_field_whitelist),
176
+ )
177
+ object.__setattr__(
178
+ self,
179
+ "model_field_blacklist",
180
+ _normalize_nested_sets(self.model_field_blacklist),
181
+ )
182
+ object.__setattr__(
183
+ self,
184
+ "procedure_whitelist",
185
+ _normalize_tuple(self.procedure_whitelist),
186
+ )
187
+ object.__setattr__(
188
+ self,
189
+ "procedure_blacklist",
190
+ _normalize_tuple(self.procedure_blacklist),
191
+ )
192
+ object.__setattr__(
193
+ self,
194
+ "operator_registry",
195
+ self._build_operator_registry(),
196
+ )
197
+ if (
198
+ self.like_escape_character is not None
199
+ and len(self.like_escape_character) != 1
200
+ ):
201
+ raise ValueError(
202
+ "like_escape_character must be a single character when set."
203
+ )
204
+ object.__setattr__(self, "_field_policy", self._build_field_policy())
205
+ object.__setattr__(
206
+ self,
207
+ "_field_converter_set",
208
+ self._build_field_converter_set(),
209
+ )
210
+ object.__setattr__(
211
+ self,
212
+ "_procedure_policy",
213
+ self._build_procedure_policy(),
214
+ )
215
+
216
+ def _build_operator_registry(self) -> OperatorRegistry:
217
+ """Extends the configured operator registry with custom predicates."""
218
+ if not self.custom_predicates:
219
+ return self.operator_registry
220
+
221
+ merged_operators = list(self.operator_registry.operators)
222
+ operators_by_name = {
223
+ operator.name: operator for operator in merged_operators
224
+ }
225
+ for operator_name, definition in self.custom_predicates.items():
226
+ if definition.operator.name != operator_name:
227
+ raise ValueError(
228
+ "custom_predicates keys must match their operator names."
229
+ )
230
+ existing_operator = operators_by_name.get(operator_name)
231
+ if existing_operator is None:
232
+ merged_operators.append(definition.operator)
233
+ operators_by_name[operator_name] = definition.operator
234
+ continue
235
+ if existing_operator != definition.operator:
236
+ raise ValueError(
237
+ "custom_predicates cannot redefine an existing "
238
+ f"operator differently: {operator_name!r}."
239
+ )
240
+ return OperatorRegistry(operators=tuple(merged_operators))
241
+
242
+ @property
243
+ def field_policy(self) -> FieldPolicySet:
244
+ """The normalized field mapping and access configuration."""
245
+ return self._field_policy
246
+
247
+ @property
248
+ def field_converter_set(self) -> FieldValueConverterSet:
249
+ """The normalized field-scoped converter configuration."""
250
+ return self._field_converter_set
251
+
252
+ @property
253
+ def procedure_policy(self) -> ProcedureAccessPolicy:
254
+ """The compiled procedure access policy."""
255
+ return self._procedure_policy
256
+
257
+ def _build_field_policy(self) -> FieldPolicySet:
258
+ """Builds the immutable field-policy object once."""
259
+ return _build_field_policy(
260
+ field_mapping=self.field_mapping,
261
+ field_whitelist=self.field_whitelist,
262
+ field_blacklist=self.field_blacklist,
263
+ model_field_mapping=self.model_field_mapping,
264
+ model_field_whitelist=self.model_field_whitelist,
265
+ model_field_blacklist=self.model_field_blacklist,
266
+ )
267
+
268
+ def _build_field_converter_set(self) -> FieldValueConverterSet:
269
+ """Builds the immutable field-converter object once."""
270
+ return FieldValueConverterSet(
271
+ field_converters=self.field_value_converters,
272
+ model_field_converters=self.model_field_value_converters,
273
+ )
274
+
275
+ def _build_procedure_policy(self) -> ProcedureAccessPolicy:
276
+ """Builds the compiled procedure policy once."""
277
+ return _build_procedure_policy(
278
+ self.procedure_whitelist,
279
+ self.procedure_blacklist,
280
+ )
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class SortOptions:
285
+ """ORM-neutral sort options."""
286
+
287
+ field_mapping: Mapping[str, str] = field(default_factory=dict)
288
+ model_field_mapping: Mapping[type[Any], Mapping[str, str]] = field(
289
+ default_factory=dict
290
+ )
291
+ join_hints: Mapping[str, JoinHint] = field(default_factory=dict)
292
+ field_whitelist: frozenset[str] = field(default_factory=frozenset)
293
+ field_blacklist: frozenset[str] = field(default_factory=frozenset)
294
+ model_field_whitelist: Mapping[type[Any], frozenset[str]] = field(
295
+ default_factory=dict
296
+ )
297
+ model_field_blacklist: Mapping[type[Any], frozenset[str]] = field(
298
+ default_factory=dict
299
+ )
300
+ procedure_whitelist: tuple[str, ...] = ()
301
+ procedure_blacklist: tuple[str, ...] = ()
302
+ sort_limits: SortLimits = field(default_factory=SortLimits)
303
+ json_options: JSONOptions = field(default_factory=JSONOptions)
304
+ _field_policy: FieldPolicySet = field(
305
+ init=False,
306
+ repr=False,
307
+ compare=False,
308
+ )
309
+ _procedure_policy: ProcedureAccessPolicy = field(
310
+ init=False,
311
+ repr=False,
312
+ compare=False,
313
+ )
314
+
315
+ def __post_init__(self) -> None:
316
+ """Normalizes option containers into immutable representations."""
317
+ object.__setattr__(
318
+ self,
319
+ "field_mapping",
320
+ _normalize_mapping(self.field_mapping),
321
+ )
322
+ object.__setattr__(
323
+ self,
324
+ "model_field_mapping",
325
+ _normalize_nested_mapping(self.model_field_mapping),
326
+ )
327
+ object.__setattr__(
328
+ self,
329
+ "join_hints",
330
+ _normalize_mapping(self.join_hints),
331
+ )
332
+ object.__setattr__(
333
+ self,
334
+ "field_whitelist",
335
+ _normalize_frozenset(self.field_whitelist),
336
+ )
337
+ object.__setattr__(
338
+ self,
339
+ "field_blacklist",
340
+ _normalize_frozenset(self.field_blacklist),
341
+ )
342
+ object.__setattr__(
343
+ self,
344
+ "model_field_whitelist",
345
+ _normalize_nested_sets(self.model_field_whitelist),
346
+ )
347
+ object.__setattr__(
348
+ self,
349
+ "model_field_blacklist",
350
+ _normalize_nested_sets(self.model_field_blacklist),
351
+ )
352
+ object.__setattr__(
353
+ self,
354
+ "procedure_whitelist",
355
+ _normalize_tuple(self.procedure_whitelist),
356
+ )
357
+ object.__setattr__(
358
+ self,
359
+ "procedure_blacklist",
360
+ _normalize_tuple(self.procedure_blacklist),
361
+ )
362
+ object.__setattr__(self, "_field_policy", self._build_field_policy())
363
+ object.__setattr__(
364
+ self,
365
+ "_procedure_policy",
366
+ self._build_procedure_policy(),
367
+ )
368
+
369
+ @property
370
+ def field_policy(self) -> FieldPolicySet:
371
+ """Returns the normalized field mapping and access configuration."""
372
+ return self._field_policy
373
+
374
+ @property
375
+ def procedure_policy(self) -> ProcedureAccessPolicy:
376
+ """Returns the compiled procedure access policy."""
377
+ return self._procedure_policy
378
+
379
+ def _build_field_policy(self) -> FieldPolicySet:
380
+ """Builds the immutable field-policy object once."""
381
+ return _build_field_policy(
382
+ field_mapping=self.field_mapping,
383
+ field_whitelist=self.field_whitelist,
384
+ field_blacklist=self.field_blacklist,
385
+ model_field_mapping=self.model_field_mapping,
386
+ model_field_whitelist=self.model_field_whitelist,
387
+ model_field_blacklist=self.model_field_blacklist,
388
+ )
389
+
390
+ def _build_procedure_policy(self) -> ProcedureAccessPolicy:
391
+ """Builds the compiled procedure policy once."""
392
+ return _build_procedure_policy(
393
+ self.procedure_whitelist,
394
+ self.procedure_blacklist,
395
+ )
396
+
397
+
398
+ def _normalize_nested_mapping(
399
+ mapping: Mapping[type[Any], Mapping[str, _NestedValueT]],
400
+ ) -> Mapping[type[Any], Mapping[str, _NestedValueT]]:
401
+ """Normalizes nested mapping structures into immutable views."""
402
+ return MappingProxyType(
403
+ {
404
+ model: MappingProxyType(dict(model_mapping))
405
+ for model, model_mapping in mapping.items()
406
+ }
407
+ )
408
+
409
+
410
+ def _normalize_nested_sets(
411
+ mapping: Mapping[type[Any], frozenset[str]],
412
+ ) -> Mapping[type[Any], frozenset[str]]:
413
+ """Normalizes nested set-like mappings into immutable views."""
414
+ return MappingProxyType(
415
+ {model: frozenset(values) for model, values in mapping.items()}
416
+ )
pyrsql/core/page.py ADDED
@@ -0,0 +1,115 @@
1
+ """ORM-neutral pagination request objects."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from pyrsql.core.compiler import PageCompilationResult
7
+ from pyrsql.orms.base import ORM
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class PageRequest:
12
+ """Represents an ORM-neutral pagination request.
13
+
14
+ Attributes:
15
+ page_number: Zero-based page number.
16
+ page_size: Number of rows requested per page.
17
+ """
18
+
19
+ page_number: int
20
+ page_size: int
21
+
22
+ def __post_init__(self) -> None:
23
+ """Validates pagination invariants."""
24
+ if self.page_number < 0:
25
+ raise ValueError("page_number must be greater than or equal to 0.")
26
+ if self.page_size <= 0:
27
+ raise ValueError("page_size must be greater than 0.")
28
+
29
+ @classmethod
30
+ def of(
31
+ cls,
32
+ page_number: int,
33
+ page_size: int,
34
+ ) -> "PageRequest":
35
+ """Builds a pagination request from page number and page size.
36
+
37
+ Args:
38
+ page_number: Zero-based page number.
39
+ page_size: Number of rows requested per page.
40
+
41
+ Returns:
42
+ A validated pagination request.
43
+ """
44
+ return cls(page_number=page_number, page_size=page_size)
45
+
46
+ @classmethod
47
+ def from_offset(
48
+ cls,
49
+ *,
50
+ offset: int,
51
+ limit: int,
52
+ ) -> "PageRequest":
53
+ """Builds a pagination request from offset and limit.
54
+
55
+ Args:
56
+ offset: Zero-based row offset.
57
+ limit: Maximum number of rows requested.
58
+
59
+ Returns:
60
+ A validated pagination request.
61
+ """
62
+ if offset < 0:
63
+ raise ValueError("offset must be greater than or equal to 0.")
64
+ if limit <= 0:
65
+ raise ValueError("limit must be greater than 0.")
66
+ page_number, remainder = divmod(offset, limit)
67
+ if remainder != 0:
68
+ raise ValueError(
69
+ "offset must align with limit to convert into a PageRequest."
70
+ )
71
+ return cls(page_number=page_number, page_size=limit)
72
+
73
+ @property
74
+ def offset(self) -> int:
75
+ """The zero-based row offset for this request."""
76
+ return self.page_number * self.page_size
77
+
78
+ @property
79
+ def limit(self) -> int:
80
+ """The maximum number of rows for this request."""
81
+ return self.page_size
82
+
83
+ def compile(self, *, orm: ORM) -> PageCompilationResult:
84
+ """Compiles the page request using the provided ORM.
85
+
86
+ Args:
87
+ orm: ORM adapter used to compile the page request.
88
+
89
+ Returns:
90
+ The ORM-specific page compilation result.
91
+ """
92
+ compiled_page = orm.compile_page_request(self)
93
+ return PageCompilationResult(
94
+ orm_name=orm.name,
95
+ compiled_page=compiled_page,
96
+ )
97
+
98
+ def apply(
99
+ self,
100
+ target: Any,
101
+ model: type[Any],
102
+ *,
103
+ orm: ORM,
104
+ ) -> Any:
105
+ """Compiles and applies the page request using the ORM.
106
+
107
+ Args:
108
+ target: ORM-specific target to mutate.
109
+ model: ORM model class used to resolve the page request.
110
+ orm: ORM adapter used to compile the page request.
111
+
112
+ Returns:
113
+ The value returned by the ORM-specific apply operation.
114
+ """
115
+ return self.compile(orm=orm).apply(target=target, model=model)
@@ -0,0 +1,61 @@
1
+ """Shared procedure access-policy helpers."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from re import Pattern
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ProcedureAccessPolicy:
10
+ """Regex-based access policy for selector procedures.
11
+
12
+ The policy stores compiled whitelist and blacklist patterns used to check
13
+ whether a procedure name is allowed.
14
+
15
+ Attributes:
16
+ whitelist_patterns: Compiled whitelist regular expressions.
17
+ blacklist_patterns: Compiled blacklist regular expressions.
18
+ """
19
+
20
+ whitelist_patterns: tuple[Pattern[str], ...]
21
+ blacklist_patterns: tuple[Pattern[str], ...]
22
+
23
+ @classmethod
24
+ def from_patterns(
25
+ cls,
26
+ whitelist: tuple[str, ...],
27
+ blacklist: tuple[str, ...],
28
+ ) -> "ProcedureAccessPolicy":
29
+ """Builds a policy from raw regex pattern strings.
30
+
31
+ Args:
32
+ whitelist: Raw regular expressions that allow procedures.
33
+ blacklist: Raw regular expressions that deny procedures.
34
+
35
+ Returns:
36
+ A compiled procedure access policy.
37
+ """
38
+ return cls(
39
+ whitelist_patterns=tuple(
40
+ re.compile(pattern) for pattern in whitelist
41
+ ),
42
+ blacklist_patterns=tuple(
43
+ re.compile(pattern) for pattern in blacklist
44
+ ),
45
+ )
46
+
47
+ def is_whitelisted(self, procedure_name: str) -> bool:
48
+ """Whether the procedure matches the whitelist."""
49
+ return self._matches_any(procedure_name, self.whitelist_patterns)
50
+
51
+ def is_blacklisted(self, procedure_name: str) -> bool:
52
+ """Whether the procedure matches the blacklist."""
53
+ return self._matches_any(procedure_name, self.blacklist_patterns)
54
+
55
+ def _matches_any(
56
+ self,
57
+ procedure_name: str,
58
+ patterns: tuple[Pattern[str], ...],
59
+ ) -> bool:
60
+ """Whether a value fully matches at least one pattern."""
61
+ return any(pattern.fullmatch(procedure_name) for pattern in patterns)