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
@@ -0,0 +1,81 @@
1
+ """ORM-neutral compilation result objects."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from pyrsql.orms.base import CompiledPageRequest, CompiledQuery, CompiledSort
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class CompilationResult:
11
+ """Wraps an ORM-specific compiled query object.
12
+
13
+ Attributes:
14
+ orm_name: Name of the ORM used to produce the compilation.
15
+ compiled_query: ORM-specific compiled query payload.
16
+ """
17
+
18
+ orm_name: str
19
+ compiled_query: CompiledQuery
20
+
21
+ def apply(self, target: Any, model: type[Any]) -> Any:
22
+ """Applies the compiled query to an ORM-specific target.
23
+
24
+ Args:
25
+ target: ORM-specific target to mutate.
26
+ model: ORM model class used to resolve the query.
27
+
28
+ Returns:
29
+ The value returned by the compiled query application.
30
+ """
31
+ return self.compiled_query.apply(target=target, model=model)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SortCompilationResult:
36
+ """Wraps an ORM-specific compiled sort object.
37
+
38
+ Attributes:
39
+ orm_name: Name of the ORM used to produce the compilation.
40
+ compiled_sort: ORM-specific compiled sort payload.
41
+ """
42
+
43
+ orm_name: str
44
+ compiled_sort: CompiledSort
45
+
46
+ def apply(self, target: Any, model: type[Any]) -> Any:
47
+ """Applies the compiled sort to an ORM-specific target.
48
+
49
+ Args:
50
+ target: ORM-specific target to mutate.
51
+ model: ORM model class used to resolve the sort.
52
+
53
+ Returns:
54
+ The value returned by the compiled sort application.
55
+ """
56
+ return self.compiled_sort.apply(target=target, model=model)
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class PageCompilationResult:
61
+ """Wraps an ORM-specific compiled page request object.
62
+
63
+ Attributes:
64
+ orm_name: Name of the ORM used to produce the compilation.
65
+ compiled_page: ORM-specific compiled page payload.
66
+ """
67
+
68
+ orm_name: str
69
+ compiled_page: CompiledPageRequest
70
+
71
+ def apply(self, target: Any, model: type[Any]) -> Any:
72
+ """Applies the compiled page request to an ORM-specific target.
73
+
74
+ Args:
75
+ target: ORM-specific target to mutate.
76
+ model: ORM model class used to resolve the page request.
77
+
78
+ Returns:
79
+ The value returned by the compiled page application.
80
+ """
81
+ return self.compiled_page.apply(target=target, model=model)
@@ -0,0 +1,206 @@
1
+ """ORM-neutral value conversion support."""
2
+
3
+ import datetime as dt
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal
7
+ from enum import Enum
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+ from uuid import UUID
11
+
12
+ import ciso8601
13
+
14
+ ValueConverter = Callable[[str], Any]
15
+
16
+
17
+ class ValueConversionError(ValueError):
18
+ """Raised when a raw RSQL value cannot be converted."""
19
+
20
+
21
+ def _convert_bool(raw_value: str) -> bool:
22
+ """Converts a string to bool using explicit accepted values."""
23
+ lowered = raw_value.lower()
24
+ if lowered == "true":
25
+ return True
26
+ if lowered == "false":
27
+ return False
28
+ raise ValueConversionError(f"Cannot convert {raw_value!r} to bool.")
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ValueConverterRegistry:
33
+ """Immutable registry of orm-neutral string-to-type converters."""
34
+
35
+ converters: Mapping[type[Any], ValueConverter]
36
+
37
+ def __post_init__(self) -> None:
38
+ """Normalizes the converter mapping into an immutable view."""
39
+ object.__setattr__(
40
+ self,
41
+ "converters",
42
+ MappingProxyType(dict(self.converters)),
43
+ )
44
+
45
+ def with_converter(
46
+ self,
47
+ target_type: type[Any],
48
+ converter: ValueConverter,
49
+ ) -> "ValueConverterRegistry":
50
+ """Returns a new registry with one additional converter."""
51
+ updated = dict(self.converters)
52
+ updated[target_type] = converter
53
+ return ValueConverterRegistry(updated)
54
+
55
+ def convert(
56
+ self,
57
+ raw_value: str,
58
+ target_type: type[Any] | None,
59
+ ) -> Any:
60
+ """Converts a raw string into the requested target type."""
61
+ if target_type is None:
62
+ return raw_value
63
+
64
+ if target_type is dt.datetime:
65
+ return self._convert_datetime(raw_value)
66
+
67
+ converter = self._find_registered_converter(target_type)
68
+ if converter is not None:
69
+ try:
70
+ return converter(raw_value)
71
+ except Exception as error: # pragma: no cover - defensive wrap
72
+ raise ValueConversionError(
73
+ f"Failed to convert {raw_value!r} to "
74
+ f"{target_type.__name__}."
75
+ ) from error
76
+
77
+ try:
78
+ if issubclass(target_type, Enum):
79
+ return self._convert_enum(raw_value, target_type)
80
+ if issubclass(target_type, str):
81
+ return raw_value
82
+ except TypeError as error: # pragma: no cover - invalid type object
83
+ raise ValueConversionError(
84
+ f"Unsupported target type {target_type!r}."
85
+ ) from error
86
+
87
+ return self._construct_from_string(raw_value, target_type)
88
+
89
+ def _find_registered_converter(
90
+ self,
91
+ target_type: type[Any],
92
+ ) -> ValueConverter | None:
93
+ """Finds the most specific registered converter for a type."""
94
+ for candidate in target_type.__mro__:
95
+ converter = self.converters.get(candidate)
96
+ if converter is not None:
97
+ return converter
98
+ return None
99
+
100
+ def _convert_enum(
101
+ self,
102
+ raw_value: str,
103
+ target_type: type[Any],
104
+ ) -> Any:
105
+ """Converts a string into an enum member by name first."""
106
+ try:
107
+ return target_type[raw_value]
108
+ except KeyError:
109
+ try:
110
+ return target_type(raw_value)
111
+ except ValueError as error:
112
+ raise ValueConversionError(
113
+ f"Failed to convert {raw_value!r} to "
114
+ f"{target_type.__name__}."
115
+ ) from error
116
+
117
+ def _convert_datetime(self, raw_value: str) -> dt.datetime:
118
+ """Converts a string into datetime with LocalDate-style fallback."""
119
+ try:
120
+ parsed_datetime = ciso8601.parse_datetime(raw_value)
121
+ except ValueError:
122
+ parsed_datetime = None
123
+ if parsed_datetime is not None:
124
+ return parsed_datetime
125
+ try:
126
+ parsed_date = dt.date.fromisoformat(raw_value)
127
+ except ValueError as date_error:
128
+ raise ValueConversionError(
129
+ f"Failed to convert {raw_value!r} to datetime."
130
+ ) from date_error
131
+ return dt.datetime.combine(parsed_date, dt.time.min)
132
+
133
+ def _construct_from_string(
134
+ self,
135
+ raw_value: str,
136
+ target_type: type[Any],
137
+ ) -> Any:
138
+ """Attempts a plain constructor-based conversion as a fallback."""
139
+ try:
140
+ return target_type(raw_value)
141
+ except Exception as error:
142
+ raise ValueConversionError(
143
+ f"Failed to convert {raw_value!r} to {target_type.__name__}."
144
+ ) from error
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class FieldValueConverterSet:
149
+ """Immutable field-scoped value converter configuration."""
150
+
151
+ field_converters: Mapping[str, ValueConverter]
152
+ model_field_converters: Mapping[type[Any], Mapping[str, ValueConverter]]
153
+
154
+ def __post_init__(self) -> None:
155
+ """Normalizes nested converter mappings into immutable views."""
156
+ object.__setattr__(
157
+ self,
158
+ "field_converters",
159
+ MappingProxyType(dict(self.field_converters)),
160
+ )
161
+ object.__setattr__(
162
+ self,
163
+ "model_field_converters",
164
+ MappingProxyType(
165
+ {
166
+ model: MappingProxyType(dict(converters))
167
+ for model, converters in self.model_field_converters.items()
168
+ }
169
+ ),
170
+ )
171
+
172
+ def resolve(
173
+ self,
174
+ *,
175
+ model: type[Any] | None,
176
+ field_name: str | None,
177
+ field_path: str | None,
178
+ ) -> ValueConverter | None:
179
+ """Resolves the most specific converter configured for a field."""
180
+ if model is not None and field_name is not None:
181
+ model_converters = self.model_field_converters.get(model)
182
+ if model_converters is not None:
183
+ converter = model_converters.get(field_name)
184
+ if converter is not None:
185
+ return converter
186
+ if field_path is not None:
187
+ return self.field_converters.get(field_path)
188
+ return None
189
+
190
+
191
+ DEFAULT_VALUE_CONVERTER_REGISTRY = ValueConverterRegistry(
192
+ {
193
+ bool: _convert_bool,
194
+ int: int,
195
+ float: float,
196
+ Decimal: Decimal,
197
+ UUID: UUID,
198
+ dt.date: dt.date.fromisoformat,
199
+ dt.time: dt.time.fromisoformat,
200
+ }
201
+ )
202
+
203
+ DEFAULT_FIELD_VALUE_CONVERTER_SET = FieldValueConverterSet(
204
+ field_converters={},
205
+ model_field_converters={},
206
+ )
pyrsql/core/custom.py ADDED
@@ -0,0 +1,14 @@
1
+ """ORM-neutral custom predicate definitions."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from pyrsql.parsing.operators import ComparisonOperator
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class CustomPredicateDefinition:
11
+ """Defines a custom predicate independently of any orm."""
12
+
13
+ operator: ComparisonOperator
14
+ argument_type: type[Any]
@@ -0,0 +1,97 @@
1
+ """Shared field mapping and access-policy helpers."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class FieldPolicySet:
10
+ """ORM-neutral field mapping and access-policy configuration.
11
+
12
+ Attributes:
13
+ field_mapping: Global selector-to-field mapping.
14
+ field_whitelist: Globally allowed field paths.
15
+ field_blacklist: Globally blocked field paths.
16
+ model_field_mapping: Model-specific selector-to-field mappings.
17
+ model_field_whitelist: Model-specific allowed field names.
18
+ model_field_blacklist: Model-specific blocked field names.
19
+ """
20
+
21
+ @property
22
+ def is_empty(self) -> bool:
23
+ """Whether the policy carries no active restrictions."""
24
+ return not any(
25
+ (
26
+ self.field_mapping,
27
+ self.field_whitelist,
28
+ self.field_blacklist,
29
+ self.model_field_mapping,
30
+ self.model_field_whitelist,
31
+ self.model_field_blacklist,
32
+ )
33
+ )
34
+
35
+ field_mapping: Mapping[str, str]
36
+ field_whitelist: frozenset[str]
37
+ field_blacklist: frozenset[str]
38
+ model_field_mapping: Mapping[type[Any], Mapping[str, str]]
39
+ model_field_whitelist: Mapping[type[Any], frozenset[str]]
40
+ model_field_blacklist: Mapping[type[Any], frozenset[str]]
41
+
42
+ def map_model_field(
43
+ self,
44
+ model: type[Any],
45
+ selector: str,
46
+ ) -> str:
47
+ """Maps one selector segment for a specific model when configured.
48
+
49
+ Args:
50
+ model: ORM model used to look up a model-specific mapping.
51
+ selector: Raw selector segment to map.
52
+
53
+ Returns:
54
+ The mapped selector segment, or the original value when no mapping
55
+ is configured.
56
+ """
57
+ mapping = self.model_field_mapping.get(model)
58
+ if mapping is None:
59
+ return selector
60
+ return mapping.get(selector, selector)
61
+
62
+ def validate_global_field_access(self, field_path: str) -> None:
63
+ """Validates global whitelist and blacklist rules.
64
+
65
+ Args:
66
+ field_path: Dotted field path to validate.
67
+
68
+ Raises:
69
+ ValueError: If the field path is not allowed or is blocked.
70
+ """
71
+ if self.field_whitelist and field_path not in self.field_whitelist:
72
+ raise ValueError(f"Field {field_path!r} is not allowed.")
73
+ if field_path in self.field_blacklist:
74
+ raise ValueError(f"Field {field_path!r} is blocked.")
75
+
76
+ def validate_model_field_access(
77
+ self,
78
+ model: type[Any],
79
+ field_name: str,
80
+ ) -> None:
81
+ """Validates model-specific whitelist and blacklist rules.
82
+
83
+ Args:
84
+ model: ORM model used to look up model-specific rules.
85
+ field_name: Field name to validate.
86
+
87
+ Raises:
88
+ ValueError: If the field name is not allowed or is blocked.
89
+ """
90
+ whitelist = self.model_field_whitelist.get(model)
91
+ if whitelist is not None and field_name not in whitelist:
92
+ raise ValueError(
93
+ f"Field {model.__name__}.{field_name} is not allowed."
94
+ )
95
+ blacklist = self.model_field_blacklist.get(model)
96
+ if blacklist is not None and field_name in blacklist:
97
+ raise ValueError(f"Field {model.__name__}.{field_name} is blocked.")
pyrsql/core/joins.py ADDED
@@ -0,0 +1,11 @@
1
+ """ORM-neutral join hint definitions."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class JoinHint(Enum):
7
+ """Supported orm-neutral join hint kinds."""
8
+
9
+ INNER = "inner"
10
+ LEFT = "left"
11
+ RIGHT = "right"
@@ -0,0 +1,19 @@
1
+ """ORM-neutral JSON query primitives."""
2
+
3
+ from pyrsql.core.json.options import JSONOptions
4
+ from pyrsql.core.json.path import JSONPath
5
+ from pyrsql.core.json.query import JSONPathComparison
6
+ from pyrsql.core.json.values import (
7
+ DEFAULT_JSON_SCALAR_NORMALIZER,
8
+ JSONScalarNormalizer,
9
+ JSONScalarValue,
10
+ )
11
+
12
+ __all__ = [
13
+ "DEFAULT_JSON_SCALAR_NORMALIZER",
14
+ "JSONOptions",
15
+ "JSONPath",
16
+ "JSONPathComparison",
17
+ "JSONScalarNormalizer",
18
+ "JSONScalarValue",
19
+ ]
@@ -0,0 +1,31 @@
1
+ """ORM-neutral JSON options."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class JSONOptions:
8
+ """ORM-neutral JSON behavior flags.
9
+
10
+ Attributes:
11
+ path_exists_function: SQL function used for JSON path existence.
12
+ path_exists_tz_function: SQL function used for timezone-aware JSON path
13
+ existence checks.
14
+ use_datetime: Whether JSON values should be interpreted as datetimes
15
+ when possible.
16
+ """
17
+
18
+ path_exists_function: str = "jsonb_path_exists"
19
+ path_exists_tz_function: str = "jsonb_path_exists_tz"
20
+ use_datetime: bool = False
21
+
22
+ def __post_init__(self) -> None:
23
+ """Validates configured SQL function names.
24
+
25
+ Raises:
26
+ ValueError: If either configured SQL function name is empty.
27
+ """
28
+ if not self.path_exists_function:
29
+ raise ValueError("path_exists_function cannot be empty.")
30
+ if not self.path_exists_tz_function:
31
+ raise ValueError("path_exists_tz_function cannot be empty.")
@@ -0,0 +1,35 @@
1
+ """Immutable JSON path primitives."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class JSONPath:
8
+ """Represents an ORM-neutral JSON path.
9
+
10
+ Attributes:
11
+ segments: Ordered path segments from the JSON root to the target.
12
+ """
13
+
14
+ segments: tuple[str, ...] = ()
15
+ _dot_path: str = field(init=False, repr=False, compare=False)
16
+
17
+ def __post_init__(self) -> None:
18
+ """Validates path segments.
19
+
20
+ Raises:
21
+ ValueError: If any segment is empty.
22
+ """
23
+ for segment in self.segments:
24
+ if not segment:
25
+ raise ValueError("JSON path segments cannot be empty.")
26
+ object.__setattr__(self, "_dot_path", ".".join(self.segments))
27
+
28
+ @property
29
+ def is_root(self) -> bool:
30
+ """Whether the path targets the root JSON value."""
31
+ return not self.segments
32
+
33
+ def to_dot_path(self) -> str:
34
+ """The path rendered as a dotted string."""
35
+ return self._dot_path
@@ -0,0 +1,39 @@
1
+ """ORM-neutral JSON query models."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pyrsql.core.json.path import JSONPath
6
+ from pyrsql.core.json.values import (
7
+ DEFAULT_JSON_SCALAR_NORMALIZER,
8
+ JSONScalarNormalizer,
9
+ JSONScalarValue,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class JSONPathComparison:
15
+ """Represents one JSON path comparison after semantic normalization."""
16
+
17
+ path: JSONPath
18
+ operator_name: str
19
+ values: tuple[JSONScalarValue, ...]
20
+
21
+ @classmethod
22
+ def from_raw_arguments(
23
+ cls,
24
+ *,
25
+ path: JSONPath,
26
+ operator_name: str,
27
+ raw_arguments: tuple[tuple[str, bool], ...],
28
+ normalizer: JSONScalarNormalizer | None = None,
29
+ ) -> "JSONPathComparison":
30
+ """Builds a normalized JSON comparison from raw RSQL arguments."""
31
+ value_normalizer = normalizer or DEFAULT_JSON_SCALAR_NORMALIZER
32
+ return cls(
33
+ path=path,
34
+ operator_name=operator_name,
35
+ values=tuple(
36
+ value_normalizer.normalize(raw_value, quoted=quoted)
37
+ for raw_value, quoted in raw_arguments
38
+ ),
39
+ )
@@ -0,0 +1,72 @@
1
+ """ORM-neutral JSON value normalization."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import msgspec
8
+
9
+ _INTEGER_PATTERN = re.compile(r"^-?\d+$")
10
+ _FLOAT_PATTERN = re.compile(r"^-?\d+\.\d+$")
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class JSONScalarValue:
15
+ """Represents one normalized JSON scalar or structured value."""
16
+
17
+ value: Any
18
+ json_literal: str
19
+ python_type: type[Any] | None
20
+
21
+
22
+ class JSONScalarNormalizer:
23
+ """Normalizes raw RSQL arguments to JSON-aware values."""
24
+
25
+ def normalize(
26
+ self,
27
+ raw_value: str,
28
+ *,
29
+ quoted: bool,
30
+ ) -> JSONScalarValue:
31
+ """Builds one JSON value from a raw RSQL argument."""
32
+ if quoted:
33
+ parsed_json = self._try_parse_json(raw_value)
34
+ if parsed_json is not None:
35
+ return self._from_python_value(parsed_json)
36
+ return self._from_python_value(raw_value)
37
+
38
+ match raw_value.lower():
39
+ case "true":
40
+ return self._from_python_value(True)
41
+ case "false":
42
+ return self._from_python_value(False)
43
+ case "null":
44
+ return self._from_python_value(None)
45
+ case _:
46
+ pass
47
+ if _INTEGER_PATTERN.fullmatch(raw_value):
48
+ return self._from_python_value(int(raw_value))
49
+ if _FLOAT_PATTERN.fullmatch(raw_value):
50
+ return self._from_python_value(float(raw_value))
51
+ return self._from_python_value(raw_value)
52
+
53
+ def _from_python_value(self, value: Any) -> JSONScalarValue:
54
+ """Creates a normalized JSON value from a Python object."""
55
+ return JSONScalarValue(
56
+ value=value,
57
+ json_literal=msgspec.json.encode(value).decode("utf-8"),
58
+ python_type=type(value) if value is not None else None,
59
+ )
60
+
61
+ def _try_parse_json(self, raw_value: str) -> Any | None:
62
+ """Parses quoted JSON arguments when they contain JSON values."""
63
+ try:
64
+ parsed = msgspec.json.decode(raw_value)
65
+ except msgspec.DecodeError:
66
+ return None
67
+ if isinstance(parsed, str):
68
+ return None
69
+ return parsed
70
+
71
+
72
+ DEFAULT_JSON_SCALAR_NORMALIZER = JSONScalarNormalizer()