From 81b196dc44f7fbdf99a54d782bf533703455e5f1 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 17:13:59 -0300 Subject: [PATCH 01/13] refactor: remove ciso usage --- pyproject.toml | 1 - src/pyrsql/adapters/fastapi/dependency.py | 2 +- src/pyrsql/core/conversion.py | 16 ++++--- src/pyrsql/core/json/values.py | 6 ++- tests/unit/core/test_conversion.py | 9 ++++ uv.lock | 54 ----------------------- 6 files changed, 23 insertions(+), 65 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7fc717c..5284e92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "ciso8601>=2.3,<3.0", "msgspec>=0.18,<1.0", "typing-extensions>=4.10.0; python_version < '3.11'", ] diff --git a/src/pyrsql/adapters/fastapi/dependency.py b/src/pyrsql/adapters/fastapi/dependency.py index b511e30..f18e508 100644 --- a/src/pyrsql/adapters/fastapi/dependency.py +++ b/src/pyrsql/adapters/fastapi/dependency.py @@ -65,7 +65,7 @@ def _raise_http_error( detail={ "parameter": payload.parameter, "type": payload.error_type, - "errors": [msgspec.to_builtins(item) for item in payload.details], + "errors": msgspec.to_builtins(payload.details), }, ) diff --git a/src/pyrsql/core/conversion.py b/src/pyrsql/core/conversion.py index 8925dee..832a8ee 100644 --- a/src/pyrsql/core/conversion.py +++ b/src/pyrsql/core/conversion.py @@ -11,13 +11,13 @@ from typing import TYPE_CHECKING, Any from uuid import UUID -import ciso8601 import msgspec if TYPE_CHECKING: from collections.abc import Mapping ValueConverter = Callable[[str], object] +_JSON_DECODER = msgspec.json.Decoder() class ValueConversionError(ValueError): @@ -55,11 +55,13 @@ def _convert_datetime(raw_value: str) -> dt.datetime: ISO date. """ try: - parsed_datetime = ciso8601.parse_datetime(raw_value) - except ValueError: - parsed_datetime = None - if parsed_datetime is not None: - return parsed_datetime + return msgspec.convert( + raw_value, + type=dt.datetime, + strict=False, + ) + except msgspec.ValidationError: + pass try: parsed_date = dt.date.fromisoformat(raw_value) except ValueError as date_error: @@ -215,7 +217,7 @@ def _convert_json_container( container shape, or cannot be rewrapped into the target type. """ try: - decoded = msgspec.json.decode(raw_value) + decoded = _JSON_DECODER.decode(raw_value) except msgspec.DecodeError as error: raise ValueConversionError( f"Failed to convert {raw_value!r} to {target_type.__name__}.", diff --git a/src/pyrsql/core/json/values.py b/src/pyrsql/core/json/values.py index f23f254..1055a86 100644 --- a/src/pyrsql/core/json/values.py +++ b/src/pyrsql/core/json/values.py @@ -15,6 +15,8 @@ JSONValue: TypeAlias = ( bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] | None ) +_JSON_ENCODER = msgspec.json.Encoder() +_JSON_DECODER = msgspec.json.Decoder() class JSONScalarValue(msgspec.Struct, frozen=True, gc=False, kw_only=True): @@ -69,7 +71,7 @@ def _from_python_value(value: JSONValue) -> JSONScalarValue: """ return JSONScalarValue( value=value, - json_literal=msgspec.json.encode(value).decode("utf-8"), + json_literal=_JSON_ENCODER.encode(value).decode("utf-8"), python_type=type(value) if value is not None else None, ) @@ -82,7 +84,7 @@ def _try_parse_json(raw_value: str) -> JSONValue | None: a plain string. """ try: - parsed = cast("JSONValue", msgspec.json.decode(raw_value)) + parsed = cast("JSONValue", _JSON_DECODER.decode(raw_value)) except msgspec.DecodeError: return None if isinstance(parsed, str): diff --git a/tests/unit/core/test_conversion.py b/tests/unit/core/test_conversion.py index d076001..5fecfe7 100644 --- a/tests/unit/core/test_conversion.py +++ b/tests/unit/core/test_conversion.py @@ -62,6 +62,12 @@ class CustomIdentifier: dt.datetime(2026, 5, 2, 10, 15, 30), # noqa: DTZ001 id="datetime", ), + pytest.param( + "2026-05-02T10:15:30Z", + dt.datetime, + dt.datetime(2026, 5, 2, 10, 15, 30, tzinfo=dt.timezone.utc), + id="datetime-utc", + ), ], ) def test_default_registry_converts_registered_scalar_types( @@ -147,6 +153,9 @@ def test_default_registry_converts_json_container_types( pytest.param('["a","b"]', dict, id="list-to-dict"), pytest.param('{"a":1}', list, id="dict-to-list"), pytest.param("invalid", bool, id="invalid-bool"), + pytest.param("2026-13-02", dt.date, id="invalid-date"), + pytest.param("25:15:30", dt.time, id="invalid-time"), + pytest.param("2026-99-02T10:15:30", dt.datetime, id="invalid-datetime"), ], ) def test_default_registry_raises_typed_errors_for_invalid_conversion( diff --git a/uv.lock b/uv.lock index ff0e50b..91262ff 100644 --- a/uv.lock +++ b/uv.lock @@ -214,58 +214,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] -[[package]] -name = "ciso8601" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/8a/075724aea06c98626109bfd670c27c248c87b9ba33e637f069bf46e8c4c3/ciso8601-2.3.3.tar.gz", hash = "sha256:db5d78d9fb0de8686fbad1c1c2d168ed52efb6e8bf8774ae26226e5034a46dae", size = 31909, upload-time = "2025-08-20T16:31:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c1/ebdb2614bb7a7a8ea7b496709bdec4cd0842ef38cde44203f4986df2d8f9/ciso8601-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf67a1d47a52dad19aaffb136de63263910dcab6e50d428f27416733ce81f183", size = 16077, upload-time = "2025-08-20T16:30:18.097Z" }, - { url = "https://files.pythonhosted.org/packages/e8/bb/0d100a3774c8d15b432f693e8897891c3af4536a36b0c8ed7a527f319c8f/ciso8601-2.3.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:67316d2a2d278fad3d569771b032e9bd8484c8aab842e1a2524f6433260cf9ac", size = 24112, upload-time = "2025-08-20T16:30:19.261Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/52af79a935073c4f2a31a3e73ab531dd5f41e8544eafd84ef5cc14b0c198/ciso8601-2.3.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:48e0ac5d411d186865fdf0d30529fb7ae6df7c8d622540d5274b453f0e7b935a", size = 15868, upload-time = "2025-08-20T16:30:20.436Z" }, - { url = "https://files.pythonhosted.org/packages/36/b0/6a9f59dc68dab198df18fcb47999d9d18b67765706f7d9292814def99dac/ciso8601-2.3.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9063aa362b291a72d395980e1b6479366061ec77d98ae7375aa5891abe0c6b9d", size = 40017, upload-time = "2025-08-20T16:30:21.441Z" }, - { url = "https://files.pythonhosted.org/packages/31/1f/662b51464c2873ba345db671048e441267437e1ce802f079e024e9305b5b/ciso8601-2.3.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe7b832298a70ac39ef0b3cd1ce860289a2b45d2fdca2c2acd26551e29273487", size = 40519, upload-time = "2025-08-20T16:30:22.369Z" }, - { url = "https://files.pythonhosted.org/packages/14/ec/8f9ebbc8e3330d3c2374983cfe7553592d53cdeb59a35078ce135c81d83d/ciso8601-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0e81268f84f6ed5a8f07026abed8ffa4fa54953e5763802b259e170f7bd7fb0", size = 39986, upload-time = "2025-08-20T16:30:23.582Z" }, - { url = "https://files.pythonhosted.org/packages/24/c4/cff2f87395514ae70938b71ce4ceba975e71b000fd507ad000a8cd917a0b/ciso8601-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44fdb272acdc59e94282f6155eacbff8cd9687a2a84df0bbbed2b1bd53fa8406", size = 40236, upload-time = "2025-08-20T16:30:24.827Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7e/aef1d665c5097f71ed58684009d4b5c1cbfdb02373bbb04f22e0930dff6c/ciso8601-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:74b14ffaddb890a48d03b3b97cc3f56875a4a93b3116b023add408e45b010c22", size = 17520, upload-time = "2025-08-20T16:30:25.77Z" }, - { url = "https://files.pythonhosted.org/packages/fc/30/5744492f9e7dbe60a3c92968cdb8987566f5389b8d0e5c60f6d633da45fe/ciso8601-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f068fb60b801640b4d729a3cf79f5b3075c071f0dad3a08e5bf68b89ca41aef7", size = 16076, upload-time = "2025-08-20T16:30:27.005Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c6/ce97f28a3b936a9a6c0abba9905382cb89022b8e1abb37a2150c1caf71d6/ciso8601-2.3.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:2f347401756cdd552420a4596a0535a4f8193298ff401e41fb31603e182ae302", size = 24110, upload-time = "2025-08-20T16:30:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/1af026c7959d39bdbaa6400b76ffb54437fa52698b801d51ddaa14063f0e/ciso8601-2.3.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:77e8e691ade14dd0e2ae1bcdd98475c25cd76be34b1cf43d9138bbb7ea7a8a37", size = 15871, upload-time = "2025-08-20T16:30:30.059Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1a/9ae630bf75a51755bf701660a65207b8efa2f95590408832b38e58834d57/ciso8601-2.3.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a5839ea7d2edf22e0199587e2ea71bc082b0e7ffce90389c7bdd407c05dbf230", size = 40380, upload-time = "2025-08-20T16:30:31.211Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3c/8671bde2bbf6abb8ceee82db0bc6bcd08066e7104680e3866eda6047adc1/ciso8601-2.3.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de0476ced02b965ef82c20191757f26e14878c76ce8d32a94c1e9ee14658ec6e", size = 40914, upload-time = "2025-08-20T16:30:32.096Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/433f91f19ff553653f340e77dbb12afe46de8a84a407ae01483d22ea8f7a/ciso8601-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe9303131af07e3596583e9d7faebb755d44c52c16f8077beeea1b297541fb61", size = 40154, upload-time = "2025-08-20T16:30:33.325Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/39b905b09f77f2140724707919edea2a3d34b00a9366cd7ad541aefb464e/ciso8601-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c443761b899e4e350a647b3439f8e999d6c925dc4e83887b3063b13c2a9b195", size = 40428, upload-time = "2025-08-20T16:30:34.626Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/e676e1ac5dd8523dfc2e06c799840103343dc13c650d6ed06c63a8e41d5a/ciso8601-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:e3a395ebc5932982a72841820a6bf6e5cd1d41a760cd15ffafd1d4e963c9b802", size = 17519, upload-time = "2025-08-20T16:30:35.539Z" }, - { url = "https://files.pythonhosted.org/packages/62/aa/b723a6981cfc42bbe992da23179f5dd1556e9054067985108ec6cbe34dd3/ciso8601-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e7ef14610446211c4102bf6c67f32619ab341e56db15bad6884385b43c12b064", size = 16111, upload-time = "2025-08-20T16:30:36.781Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e9/e547ec4dd75f28d8d217488130fa07767bc42fd643d61a18870487133c0e/ciso8601-2.3.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:523901aec6b0ccdf255c863ef161f476197f177c5cd33f2fbb35955c5f97fdb4", size = 24193, upload-time = "2025-08-20T16:30:38.067Z" }, - { url = "https://files.pythonhosted.org/packages/14/c8/801b78e30667cb31b4524e9dc26cbc2c03c012f9aa3f5ae21676461dc622/ciso8601-2.3.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45f8254d1fb0a41e20f98e93075db7b56504adddf65e4c8b397671feba4861ca", size = 15917, upload-time = "2025-08-20T16:30:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/44/6b/dfc56a2a4e572a2a3f8c88a66dea6a9186a8e10da7c36cc84abc31bf795c/ciso8601-2.3.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:202ca99077577683e6a84d394ff2677ec19d9f406fbf35734f68be85d2bcd3f1", size = 41324, upload-time = "2025-08-20T16:30:40.321Z" }, - { url = "https://files.pythonhosted.org/packages/7c/57/cf66171cb5807fe345b03ce9e32fd91b3a8b6e5bd95710618a9a1b0f3fab/ciso8601-2.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7cec4e31c363e87221f2561e7083ce055a82de041e822e7c3775f8ce6250a7e", size = 41804, upload-time = "2025-08-20T16:30:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/75/91/15e8871d7ae2ff0f756128e246348bdede58c08edba13cd886450ceeb304/ciso8601-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:389fef3ccc3065fa21cb6ef7d03aee63ab980591b5d87b9f0bbe349f52b16bdc", size = 41209, upload-time = "2025-08-20T16:30:42.46Z" }, - { url = "https://files.pythonhosted.org/packages/30/54/7563e20a158a4bdf3e8d13c63e02b71f9b73c662edc83cb4d5ab67171a7d/ciso8601-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4499cfbe4da092dea95ab81aefc78b98e2d7464518e6e80107cf2b9b1f65fa2", size = 41368, upload-time = "2025-08-20T16:30:43.397Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d5/6182006dd86365bb21d1f658f70c41e266ce0f97eaf353f9d7069c51851f/ciso8601-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:1df1ca3791c6f2d543f091d88e728a60a31681ff900d9eb02f1403cf31e9c177", size = 17566, upload-time = "2025-08-20T16:30:44.706Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/88154fe8247e4dcfdbaed8c6b8ccf32b1dd4389c6c95b1986bf31649eb00/ciso8601-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8afa073802c926c3244e1e5fcc5818afd3acb90fb7826a90f91ddbda0636ea70", size = 16109, upload-time = "2025-08-20T16:30:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/be/46/8d46372b3802c7201c20c8b316569f27253aaafba0cdd2cd033985e8b77e/ciso8601-2.3.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8a04e518b4adf8e35e030feaecdb4a835d39b9bb44d207e926aea8ce3447ad7c", size = 24189, upload-time = "2025-08-20T16:30:46.958Z" }, - { url = "https://files.pythonhosted.org/packages/13/80/1890e097cb76e41995de82f29c0289ca590d7135e0be3707e5b78f54350d/ciso8601-2.3.3-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:f79ad8372463ba4265981016d1648bc05f4922bc8044c4243fcbaef7a12ee9f7", size = 15925, upload-time = "2025-08-20T16:30:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e9/690a2a6beefd9d982c20adde3f09ff54a23291a699b0df7cf0c59027d9cf/ciso8601-2.3.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d5894a33f119b5ac1082df187dc58c74fe13c9c092e19ba36495c2b7cee3540b", size = 41352, upload-time = "2025-08-20T16:30:49.294Z" }, - { url = "https://files.pythonhosted.org/packages/2f/34/9a498ceb0ebd23f538e6685721c9fc4666701372c651874ed22ec46b1423/ciso8601-2.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09deebf3e326ec59d80019b4ad35175c90b99cde789c644b1496811fe3340587", size = 41866, upload-time = "2025-08-20T16:30:50.262Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0a/ee0981502aa1c9f28f7e89cf6cee08bdff2c6ed9d4289b00cceb8a1c500e/ciso8601-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3aa43ed59b2117baccc5bb760e5e53dad77cacba671d757c1e82e0a367b1f42a", size = 41271, upload-time = "2025-08-20T16:30:51.198Z" }, - { url = "https://files.pythonhosted.org/packages/fb/65/24a888240324188d8350bc24fb58a6d759c0ca43adfa77210f3d60370b56/ciso8601-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:289515aa3a3b86a9c3450bf482f634138b98788332d136751507bfdfe46e6031", size = 41411, upload-time = "2025-08-20T16:30:52.439Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1f/febc9de191acb461e02e616e5366bc2b7757277a11b4bf215d4fb79516a8/ciso8601-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:e7288068a5bffbcc50cbe9cdaf3971f541fcd209c194fa6a59ad06066a3dcff0", size = 17573, upload-time = "2025-08-20T16:30:53.759Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3a/54ad0ae2257870076b4990545a8f16221470fecea0aa7a4e1f39506db8c5/ciso8601-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82db4047d74d8b1d129e7a8da578518729912c3bd19cb71541b147e41f426381", size = 16115, upload-time = "2025-08-20T16:30:54.971Z" }, - { url = "https://files.pythonhosted.org/packages/23/fb/9fe767d44520691e2b706769466852fbdeb44a82dc294c2766bce1049d22/ciso8601-2.3.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a553f3fc03a2ed5ca6f5716de0b314fa166461df01b45d8b36043ccac3a5e79f", size = 24214, upload-time = "2025-08-20T16:30:56.359Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ac/984fd3948f372c46c436a2b48da43f4fb7bc6f156a6f4bc858adaab79d42/ciso8601-2.3.3-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:ff59c26083b7bef6df4f0d96e4b649b484806d3d7bcc2de14ad43147c3aafb04", size = 15929, upload-time = "2025-08-20T16:30:58.352Z" }, - { url = "https://files.pythonhosted.org/packages/de/3a/5572917d4e0bec2c1ef0eda8652f9dc8d1850d29d3eef9e5e82ffe5d6791/ciso8601-2.3.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99a1fa5a730790431d0bfcd1f3a6387f60cddc6853d8dcc5c2e140cd4d67a928", size = 41578, upload-time = "2025-08-20T16:30:59.351Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cf/07321ce5cf099b98de0c02cd4bab4818610da69743003e94c8fb6e8a59cb/ciso8601-2.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c35265c1b0bd2ac30ed29b49818dd38b0d1dfda43086af605d8b91722727dec0", size = 42085, upload-time = "2025-08-20T16:31:00.338Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c7/3c521d6779ee433d9596eb3fcded79549bbe371843f25e62006c04f74dc9/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa9df2f84ab25454f14df92b2dd4f9aae03dbfa581565a716b3e89b8e2110c03", size = 41313, upload-time = "2025-08-20T16:31:01.313Z" }, - { url = "https://files.pythonhosted.org/packages/f9/93/efd40db0d6b512be1cbe4e7e750882c2e88f580e17f35b3e9cc9c23004b5/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32e06a35eb251cfc4bbe01a858c598da0a160e4ad7f42ff52477157ceaf48061", size = 41443, upload-time = "2025-08-20T16:31:02.357Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/515f9404faa39af8df5e2b899cafbca5dbe7cd2ffe5cc124ef393ffdaf1c/ciso8601-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:7657ba9730dc1340d73b9e61eca14f341c41dd308128c808b8b084d2b85bc03e", size = 17977, upload-time = "2025-08-20T16:31:03.429Z" }, - { url = "https://files.pythonhosted.org/packages/83/e5/eee65bc8c91e5981ed3440dbd4e546ff14b67deba07f6f346de1a61f28c0/ciso8601-2.3.3-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d88ab28ecb3626e3417c564e8aec9d0245b4eb75e773d2e7f3f095ea9897ded", size = 16956, upload-time = "2025-08-20T16:31:24.869Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/809cba0f1928d1d45a4e5c9d789b06fd092a145702d32a41394f4b9665ca/ciso8601-2.3.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d5a37798bf0cab6144daa2b6d07657ab1a63df540de24c23a809fb2bdf36149", size = 18285, upload-time = "2025-08-20T16:31:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1d/025db546af38ab5236086f462292c50a1f9a4b248a309129a85bb1113996/ciso8601-2.3.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d5b18c75c66499ef22cb47b429e3b5a137db5a68674365b9ca3cd0e4488d229f", size = 16957, upload-time = "2025-08-20T16:31:27.503Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/976d9c4b79e28cbda95b1acf574b00f811d9aec0fce55b63d573d6fa446b/ciso8601-2.3.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58799673ffdf621fe138fb8af6a89daf4ddefdf7ca4a10777ad8d55f3f171b6e", size = 18284, upload-time = "2025-08-20T16:31:28.43Z" }, -] - [[package]] name = "click" version = "8.3.3" @@ -1210,7 +1158,6 @@ name = "pyrsql" version = "0.0.2" source = { editable = "." } dependencies = [ - { name = "ciso8601" }, { name = "msgspec" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -1251,7 +1198,6 @@ typing = [ [package.metadata] requires-dist = [ - { name = "ciso8601", specifier = ">=2.3,<3.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.110,<1.0" }, { name = "msgspec", specifier = ">=0.18,<1.0" }, { name = "sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=2.0,<3.0" }, From 3b801b33b9ab5dbcc3b64125468b7311010db8f4 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 18:12:24 -0300 Subject: [PATCH 02/13] chore: implements support for python 3.14 free threaded --- docs/testing.md | 20 ++++ .../fastapi/sqlalchemy/integration.py | 87 +++++++------- .../fastapi/sqlalchemy/resource.py | 83 +++++++------ src/pyrsql/orms/sqlalchemy/introspection.py | 113 +++++++++--------- src/pyrsql/orms/sqlalchemy/resolver.py | 27 +++-- .../fastapi_sqlalchemy/test_integration.py | 107 +++++++++++++++++ tests/unit/orms/sqlalchemy/test_resolver.py | 82 ++++++++++++- 7 files changed, 369 insertions(+), 150 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 13ee84f..602e2ae 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -150,3 +150,23 @@ Registered markers (in `pyproject.toml`): - `performance` - hotspot benchmarks - `sqlalchemy` - SQLAlchemy-dependent tests - `fastapi` - FastAPI-dependent tests + +## Free-Threaded Validation + +For free-threaded Python support, correctness depends primarily on avoiding +unsynchronized access to shared mutable state. In `pyrsql`, this means: + +- immutable configuration/value objects should remain shareable across threads +- caches shared by integrations and ORM helpers should use explicit locking +- tests should exercise concurrent access to those caches directly + +Recommended validation flow: + +```bash +uv run pytest tests/unit/integrations/fastapi_sqlalchemy/test_integration.py \ + tests/unit/orms/sqlalchemy/test_resolver.py +``` + +When a free-threaded CPython build is available locally, run the same tests +with the GIL disabled as part of regression checks, for example with +`PYTHON_GIL=0` or `-X gil=0` depending on how Python was installed. diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index 4ea0542..e3d35f4 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import replace +from threading import RLock +from types import MappingProxyType from typing import TYPE_CHECKING from fastapi import Depends @@ -39,9 +41,8 @@ ) from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect -_DEFAULT_SQLALCHEMY_ORM = SQLAlchemyORM() _DEFAULT_FASTAPI_CRITERIA_CONFIG = FastAPICriteriaConfig() -_EMPTY_OPENAPI_EXAMPLES: dict[str, dict[str, object]] = {} +_EMPTY_OPENAPI_EXAMPLES = MappingProxyType({}) class FastAPISQLAlchemyIntegration: @@ -49,6 +50,7 @@ class FastAPISQLAlchemyIntegration: __slots__ = ( "_base_selects", + "_cache_lock", "_count_select_dependencies", "_criteria_dependency", "_paginated_select_dependencies", @@ -78,11 +80,12 @@ def __init__( raise TypeError( "criteria_config must be a FastAPICriteriaConfig or None.", ) - self.orm = orm or _DEFAULT_SQLALCHEMY_ORM + self.orm = orm or SQLAlchemyORM() self.criteria_config = ( criteria_config or _DEFAULT_FASTAPI_CRITERIA_CONFIG ) self._criteria_dependency = CriteriaDependency(self.criteria_config) + self._cache_lock = RLock() self._select_dependencies: dict[ SQLAlchemyModel, Callable[..., SQLAlchemySelect], @@ -159,12 +162,13 @@ def _base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: Returns: A cached base ``select(model)`` statement. """ - statement = self._base_selects.get(model) - if statement is not None: + with self._cache_lock: + statement = self._base_selects.get(model) + if statement is not None: + return statement + statement = select(model) + self._base_selects[model] = statement return statement - statement = select(model) - self._base_selects[model] = statement - return statement def base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: """Returns the cached base select for a model. @@ -265,18 +269,19 @@ def select_dependency( Returns: A FastAPI dependency that yields a filtered select statement. """ - cached_dependency = self._select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() + with self._cache_lock: + cached_dependency = self._select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - return self.select(model, criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + return self.select(model, criteria) - self._select_dependencies[model] = dependency - return dependency + self._select_dependencies[model] = dependency + return dependency def count_select_dependency( self, @@ -287,18 +292,19 @@ def count_select_dependency( Returns: A FastAPI dependency that yields a count select statement. """ - cached_dependency = self._count_select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() + with self._cache_lock: + cached_dependency = self._count_select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - return self.count_select(model, criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + return self.count_select(model, criteria) - self._count_select_dependencies[model] = dependency - return dependency + self._count_select_dependencies[model] = dependency + return dependency def paginated_select_dependency( self, @@ -309,18 +315,19 @@ def paginated_select_dependency( Returns: A FastAPI dependency that yields paired list and count statements. """ - cached_dependency = self._paginated_select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemyPaginatedSelect: - return self.paginated_select(model, criteria) - - self._paginated_select_dependencies[model] = dependency - return dependency + with self._cache_lock: + cached_dependency = self._paginated_select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemyPaginatedSelect: + return self.paginated_select(model, criteria) + + self._paginated_select_dependencies[model] = dependency + return dependency def resource( self, diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py index d3008bf..be21d90 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py @@ -2,6 +2,7 @@ from __future__ import annotations +from threading import RLock from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from fastapi import Depends @@ -67,6 +68,7 @@ class FastAPISQLAlchemyResource: __slots__ = ( "_applier_dependency", + "_cache_lock", "_count_select_dependency", "_criteria_dependency", "_default_sort", @@ -115,6 +117,7 @@ def __init__( self.criteria_config = criteria_config self._default_sort = default_sort self._statement_factory = statement_factory + self._cache_lock = RLock() self._applier_dependency: ( Callable[..., Callable[[SQLAlchemySelect], SQLAlchemySelect]] | None ) = None @@ -255,17 +258,18 @@ def select_dependency(self) -> Callable[..., SQLAlchemySelect]: Returns: A FastAPI dependency that yields a filtered select statement. """ - if self._select_dependency is not None: - return self._select_dependency - criteria_dependency = self.criteria_dependency() + with self._cache_lock: + if self._select_dependency is not None: + return self._select_dependency + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - return self.select(criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + return self.select(criteria) - self._select_dependency = dependency - return dependency + self._select_dependency = dependency + return dependency def applier_dependency( self, @@ -275,17 +279,18 @@ def applier_dependency( Returns: A FastAPI dependency that yields a select applier callable. """ - if self._applier_dependency is not None: - return self._applier_dependency - criteria_dependency = self.criteria_dependency() + with self._cache_lock: + if self._applier_dependency is not None: + return self._applier_dependency + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> Callable[[SQLAlchemySelect], SQLAlchemySelect]: - return self.applier(criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> Callable[[SQLAlchemySelect], SQLAlchemySelect]: + return self.applier(criteria) - self._applier_dependency = dependency - return dependency + self._applier_dependency = dependency + return dependency def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: """Returns a FastAPI dependency yielding a count select. @@ -293,17 +298,18 @@ def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: Returns: A FastAPI dependency that yields a count select statement. """ - if self._count_select_dependency is not None: - return self._count_select_dependency - criteria_dependency = self.criteria_dependency() + with self._cache_lock: + if self._count_select_dependency is not None: + return self._count_select_dependency + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - return self.count_select(criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + return self.count_select(criteria) - self._count_select_dependency = dependency - return dependency + self._count_select_dependency = dependency + return dependency def paginated_select_dependency( self, @@ -313,14 +319,15 @@ def paginated_select_dependency( Returns: A FastAPI dependency that yields paginated SQLAlchemy statements. """ - if self._paginated_select_dependency is not None: - return self._paginated_select_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemyPaginatedSelect: - return self.paginated_select(criteria) - - self._paginated_select_dependency = dependency - return dependency + with self._cache_lock: + if self._paginated_select_dependency is not None: + return self._paginated_select_dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemyPaginatedSelect: + return self.paginated_select(criteria) + + self._paginated_select_dependency = dependency + return dependency diff --git a/src/pyrsql/orms/sqlalchemy/introspection.py b/src/pyrsql/orms/sqlalchemy/introspection.py index 9a55f21..32f7a4c 100644 --- a/src/pyrsql/orms/sqlalchemy/introspection.py +++ b/src/pyrsql/orms/sqlalchemy/introspection.py @@ -2,6 +2,7 @@ from __future__ import annotations +from threading import RLock from typing import TYPE_CHECKING, Any from sqlalchemy import inspect @@ -22,10 +23,11 @@ class SQLAlchemyModelInspector: """Provides stable access to public SQLAlchemy ORM inspection APIs.""" - __slots__ = ("_attribute_cache", "_mapper_cache") + __slots__ = ("_attribute_cache", "_cache_lock", "_mapper_cache") def __init__(self) -> None: """Initializes empty caches for mapped models and attributes.""" + self._cache_lock = RLock() self._mapper_cache: dict[type[Any], Mapper[Any]] = {} self._attribute_cache: dict[ tuple[type[Any], str], @@ -41,21 +43,23 @@ def inspect_model(self, model: type[Any]) -> Mapper[Any]: Raises: SQLAlchemyModelInspectionError: If the model is not mapped. """ - cached_mapper = self._mapper_cache.get(model) - if cached_mapper is not None: - return cached_mapper - try: - mapper = inspect(model) - except NoInspectionAvailable as error: - raise SQLAlchemyModelInspectionError( - f"Type {model!r} is not a SQLAlchemy mapped class.", - ) from error - if not isinstance(mapper, Mapper): - raise SQLAlchemyModelInspectionError( - f"Type {model!r} did not resolve to a SQLAlchemy ORM mapper.", - ) - self._mapper_cache[model] = mapper - return mapper + with self._cache_lock: + cached_mapper = self._mapper_cache.get(model) + if cached_mapper is not None: + return cached_mapper + try: + mapper = inspect(model) + except NoInspectionAvailable as error: + raise SQLAlchemyModelInspectionError( + f"Type {model!r} is not a SQLAlchemy mapped class.", + ) from error + if not isinstance(mapper, Mapper): + raise SQLAlchemyModelInspectionError( + f"Type {model!r} did not resolve to a SQLAlchemy ORM " + "mapper.", + ) + self._mapper_cache[model] = mapper + return mapper def get_mapped_attribute( self, @@ -70,46 +74,47 @@ def get_mapped_attribute( Raises: SQLAlchemyModelInspectionError: If the attribute is not mapped. """ - cache_key = (model, attribute_name) - cached_attribute = self._attribute_cache.get(cache_key) - if cached_attribute is not None: - return cached_attribute - - mapper = self.inspect_model(model) - if attribute_name in mapper.relationships: - relationship = mapper.relationships[attribute_name] - return self._cache_mapped_attribute( - cache_key, - SQLAlchemyMappedAttribute( - name=attribute_name, - kind=SQLAlchemyAttributeKind.RELATIONSHIP, - owner_model=model, - attribute=getattr(model, attribute_name), - mapper=relationship.mapper, - python_type=relationship.mapper.class_, - is_collection=bool(relationship.uselist), - ), - ) - if attribute_name in mapper.column_attrs: - column_property = mapper.column_attrs[attribute_name] - return self._cache_mapped_attribute( - cache_key, - SQLAlchemyMappedAttribute( - name=attribute_name, - kind=SQLAlchemyAttributeKind.COLUMN, - owner_model=model, - attribute=getattr(model, attribute_name), - mapper=None, - python_type=self._resolve_column_python_type( - column_property, + with self._cache_lock: + cache_key = (model, attribute_name) + cached_attribute = self._attribute_cache.get(cache_key) + if cached_attribute is not None: + return cached_attribute + + mapper = self.inspect_model(model) + if attribute_name in mapper.relationships: + relationship = mapper.relationships[attribute_name] + return self._cache_mapped_attribute( + cache_key, + SQLAlchemyMappedAttribute( + name=attribute_name, + kind=SQLAlchemyAttributeKind.RELATIONSHIP, + owner_model=model, + attribute=getattr(model, attribute_name), + mapper=relationship.mapper, + python_type=relationship.mapper.class_, + is_collection=bool(relationship.uselist), + ), + ) + if attribute_name in mapper.column_attrs: + column_property = mapper.column_attrs[attribute_name] + return self._cache_mapped_attribute( + cache_key, + SQLAlchemyMappedAttribute( + name=attribute_name, + kind=SQLAlchemyAttributeKind.COLUMN, + owner_model=model, + attribute=getattr(model, attribute_name), + mapper=None, + python_type=self._resolve_column_python_type( + column_property, + ), + is_json=self._is_json_column(column_property), ), - is_json=self._is_json_column(column_property), - ), + ) + raise SQLAlchemyModelInspectionError( + f"Attribute {attribute_name!r} is not mapped on model " + f"{model.__name__!r}.", ) - raise SQLAlchemyModelInspectionError( - f"Attribute {attribute_name!r} is not mapped on model " - f"{model.__name__!r}.", - ) def _cache_mapped_attribute( self, diff --git a/src/pyrsql/orms/sqlalchemy/resolver.py b/src/pyrsql/orms/sqlalchemy/resolver.py index 4166c6f..e862e7b 100644 --- a/src/pyrsql/orms/sqlalchemy/resolver.py +++ b/src/pyrsql/orms/sqlalchemy/resolver.py @@ -2,6 +2,7 @@ from __future__ import annotations +from threading import RLock from typing import TYPE_CHECKING, Any, Final, cast from pyrsql.core.joins import JoinHint @@ -31,7 +32,7 @@ class SQLAlchemyPathResolver: """Resolves bound field paths into ORM joins and leaf attributes.""" - __slots__ = ("_default_resolution_cache", "_inspector") + __slots__ = ("_cache_lock", "_default_resolution_cache", "_inspector") def __init__( self, @@ -39,6 +40,7 @@ def __init__( inspector: SQLAlchemyModelInspector | None = None, ) -> None: """Initializes the resolver with an optional shared inspector.""" + self._cache_lock = RLock() self._inspector = inspector or SQLAlchemyModelInspector() self._default_resolution_cache: dict[ tuple[type[Any], str], @@ -58,17 +60,18 @@ def resolve( The resolved SQLAlchemy path. """ if field_policy is None or field_policy.is_empty: - cache_key = (model, field_path) - cached_path = self._default_resolution_cache.get(cache_key) - if cached_path is not None: - return cached_path - resolved_path = self._resolve_with_field_policy( - model, - field_path, - field_policy=None, - ) - self._default_resolution_cache[cache_key] = resolved_path - return resolved_path + with self._cache_lock: + cache_key = (model, field_path) + cached_path = self._default_resolution_cache.get(cache_key) + if cached_path is not None: + return cached_path + resolved_path = self._resolve_with_field_policy( + model, + field_path, + field_policy=None, + ) + self._default_resolution_cache[cache_key] = resolved_path + return resolved_path return self._resolve_with_field_policy( model, field_path, diff --git a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py index ddbd0d7..403b6d0 100644 --- a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py +++ b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py @@ -2,6 +2,8 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor +import time from typing import TYPE_CHECKING, Any, cast from unittest.mock import Mock, sentinel @@ -14,6 +16,7 @@ FastAPISQLAlchemyResource, SQLAlchemyPaginatedSelect, ) +import pyrsql.integrations.fastapi.sqlalchemy.integration as integration_module import pyrsql.integrations.fastapi.sqlalchemy.resource as resource_module from .conftest import OtherModel, User @@ -30,6 +33,16 @@ pytestmark = [pytest.mark.fastapi, pytest.mark.sqlalchemy] +def _collect_concurrent_results( + factory: Callable[[], object], + *, + workers: int = 8, +) -> list[object]: + """Runs one factory concurrently and collects all returned values.""" + with ThreadPoolExecutor(max_workers=workers) as executor: + return list(executor.map(lambda _: factory(), range(workers))) + + def test_integration_exposes_configured_criteria_dependency( fastapi_criteria_config: FastAPICriteriaConfig, sqlalchemy_orm: SQLAlchemyORM, @@ -97,6 +110,56 @@ def test_integration_reuses_cached_dependencies( ) is integration.paginated_select_dependency(User) +def test_default_integrations_do_not_share_the_same_orm_instance() -> None: + """Allocates a distinct ORM instance per default integration.""" + left = FastAPISQLAlchemyIntegration() + right = FastAPISQLAlchemyIntegration() + + assert left.orm is not right.orm + + +def test_integration_base_select_cache_is_safe_under_concurrency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Builds one cached base select per model under concurrent access.""" + integration = FastAPISQLAlchemyIntegration() + original_select = integration_module.select + call_count = 0 + + def slow_select(*args: object, **kwargs: object) -> Select[Any]: + nonlocal call_count + call_count += 1 + time.sleep(0.02) + return cast("Select[Any]", original_select(*args, **kwargs)) + + monkeypatch.setattr( + "pyrsql.integrations.fastapi.sqlalchemy.integration.select", + slow_select, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + statements = list( + executor.map( + lambda _: integration.base_select(User), + range(8), + ) + ) + + assert call_count == 1 + assert all(statement is statements[0] for statement in statements) + + +def test_integration_dependency_cache_is_safe_under_concurrency() -> None: + """Builds one dependency callable per model under concurrent access.""" + integration = FastAPISQLAlchemyIntegration() + + dependencies = _collect_concurrent_results( + lambda: integration.select_dependency(User), + ) + + assert all(dependency is dependencies[0] for dependency in dependencies) + + def test_integration_apply_delegates_to_request_criteria( integration: FastAPISQLAlchemyIntegration, base_statement: Select[Any], @@ -448,6 +511,50 @@ def test_resource_reuses_cached_dependencies( ) +def test_resource_select_dependency_cache_is_safe_under_concurrency( + integration: FastAPISQLAlchemyIntegration, +) -> None: + """Builds one select dependency callable under concurrent access.""" + resource = integration.resource(User, default_sort="-name") + dependencies = _collect_concurrent_results(resource.select_dependency) + + assert all(dependency is dependencies[0] for dependency in dependencies) + + +def test_resource_applier_dependency_cache_is_safe_under_concurrency( + integration: FastAPISQLAlchemyIntegration, +) -> None: + """Builds one applier dependency callable under concurrent access.""" + resource = integration.resource(User, default_sort="-name") + dependencies = _collect_concurrent_results(resource.applier_dependency) + + assert all(dependency is dependencies[0] for dependency in dependencies) + + +def test_resource_count_dependency_cache_is_safe_under_concurrency( + integration: FastAPISQLAlchemyIntegration, +) -> None: + """Builds one count dependency callable under concurrent access.""" + resource = integration.resource(User, default_sort="-name") + dependencies = _collect_concurrent_results( + resource.count_select_dependency, + ) + + assert all(dependency is dependencies[0] for dependency in dependencies) + + +def test_resource_paginated_dependency_cache_is_safe_under_concurrency( + integration: FastAPISQLAlchemyIntegration, +) -> None: + """Builds one paginated dependency callable under concurrent access.""" + resource = integration.resource(User, default_sort="-name") + dependencies = _collect_concurrent_results( + resource.paginated_select_dependency, + ) + + assert all(dependency is dependencies[0] for dependency in dependencies) + + @pytest.mark.parametrize( "method_name", ["select", "count_select", "paginated_select"], diff --git a/tests/unit/orms/sqlalchemy/test_resolver.py b/tests/unit/orms/sqlalchemy/test_resolver.py index 57e263d..4716623 100644 --- a/tests/unit/orms/sqlalchemy/test_resolver.py +++ b/tests/unit/orms/sqlalchemy/test_resolver.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from concurrent.futures import ThreadPoolExecutor +import time import pytest @@ -10,14 +11,12 @@ from pyrsql.core.joins import JoinHint from pyrsql.core.json.path import JSONPath from pyrsql.orms.sqlalchemy.errors import SQLAlchemyPathResolutionError +from pyrsql.orms.sqlalchemy.introspection import SQLAlchemyModelInspector +from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver from .conftest import Address, Company, Event, User -if TYPE_CHECKING: - from pyrsql.orms.sqlalchemy.introspection import SQLAlchemyModelInspector - from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver - -pytestmark = [pytest.mark.sqlalchemy] +pytestmark = pytest.mark.sqlalchemy @pytest.mark.parametrize( @@ -63,6 +62,41 @@ def test_model_inspector_marks_collection_relationships( assert mapped_attribute.mapper.class_ is Address +def test_model_inspector_attribute_cache_is_safe_under_concurrency( + model_inspector: SQLAlchemyModelInspector, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Builds one cached attribute description under concurrent access.""" + original_inspect_model = model_inspector.inspect_model + call_count = 0 + + def slow_inspect_model( + self: SQLAlchemyModelInspector, + model: type[object], + ): + nonlocal call_count + call_count += 1 + time.sleep(0.02) + return original_inspect_model(model) + + monkeypatch.setattr( + SQLAlchemyModelInspector, + "inspect_model", + slow_inspect_model, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + attributes = list( + executor.map( + lambda _: model_inspector.get_mapped_attribute(User, "name"), + range(8), + ) + ) + + assert call_count == 1 + assert all(attribute is attributes[0] for attribute in attributes) + + @pytest.mark.parametrize( ( "field_path", @@ -155,6 +189,42 @@ def test_path_resolver_resolves_relationship_joins_with_metadata( assert resolved.joins[0].is_collection is False +def test_path_resolver_default_cache_is_safe_under_concurrency( + path_resolver: SQLAlchemyPathResolver, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Builds one cached resolved path under concurrent access.""" + original_resolve = SQLAlchemyPathResolver._resolve_with_field_policy + call_count = 0 + + def slow_resolve( + self: SQLAlchemyPathResolver, + *args: object, + **kwargs: object, + ): + nonlocal call_count + call_count += 1 + time.sleep(0.02) + return original_resolve(self, *args, **kwargs) + + monkeypatch.setattr( + SQLAlchemyPathResolver, + "_resolve_with_field_policy", + slow_resolve, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + resolved_paths = list( + executor.map( + lambda _: path_resolver.resolve(User, "company.name"), + range(8), + ) + ) + + assert call_count == 1 + assert all(resolved is resolved_paths[0] for resolved in resolved_paths) + + def test_path_resolver_marks_collection_relationship_joins( path_resolver: SQLAlchemyPathResolver, ) -> None: From 20893ab99078fc6b6c42314bc45a1b5e1c55363b Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 18:22:18 -0300 Subject: [PATCH 03/13] chore: add async functional tests for FastAPI and SQLAlchemy integration --- pyproject.toml | 1 + .../fastapi_adapter/test_async_dependency.py | 114 +++++++++ .../test_async_integration.py | 234 ++++++++++++++++++ .../sqlalchemy/test_async_pipeline.py | 104 ++++++++ uv.lock | 13 + 5 files changed, 466 insertions(+) create mode 100644 tests/functional/fastapi_adapter/test_async_dependency.py create mode 100644 tests/functional/fastapi_sqlalchemy/test_async_integration.py create mode 100644 tests/integration/sqlalchemy/test_async_pipeline.py diff --git a/pyproject.toml b/pyproject.toml index 5284e92..a7755cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ test = [ "pytest>=8.2.0", "httpx>=0.27.0", "pytest-cov>=5.0.0", + "aiosqlite>=0.20.0", ] typing = ["mypy>=2.0.0"] docs = [ diff --git a/tests/functional/fastapi_adapter/test_async_dependency.py b/tests/functional/fastapi_adapter/test_async_dependency.py new file mode 100644 index 0000000..269785a --- /dev/null +++ b/tests/functional/fastapi_adapter/test_async_dependency.py @@ -0,0 +1,114 @@ +"""Async functional tests for the FastAPI criteria dependency.""" + +from typing import Annotated, Any + +import pytest + +pytest.importorskip("fastapi") +from fastapi import Depends, FastAPI +from httpx import ASGITransport, AsyncClient + +from pyrsql.adapters.fastapi import ( + FastAPICriteriaConfig, + RequestCriteria, + criteria_dependency, +) +from pyrsql.core.options import QueryOptions + +pytestmark = [pytest.mark.functional, pytest.mark.fastapi, pytest.mark.anyio] + + +async def test_async_dependency_returns_empty_when_no_params_are_provided() -> ( + None +): + """Returns an empty criteria object in an async FastAPI route.""" + app = FastAPI() + dependency = criteria_dependency() + + @app.get("/items") + async def list_items( + criteria: Annotated[RequestCriteria, Depends(dependency)], + ) -> dict[str, Any]: + return {"is_empty": criteria.is_empty} + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get("/items") + + assert response.status_code == 200 + assert response.json() == {"is_empty": True} + + +async def test_async_dependency_parses_filter_sort_and_page_params() -> None: + """Builds full request criteria from async FastAPI query params.""" + app = FastAPI() + dependency = criteria_dependency( + FastAPICriteriaConfig(default_page_size=25), + ) + + @app.get("/items") + async def list_items( + criteria: Annotated[RequestCriteria, Depends(dependency)], + ) -> dict[str, Any]: + page_request = criteria.page_request + assert page_request is not None + return { + "query": ( + criteria.query.text if criteria.query is not None else None + ), + "sort": criteria.sort.text if criteria.sort is not None else None, + "page": page_request.page_number, + "size": page_request.page_size, + } + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get( + "/items", + params={"filter": "name==demo", "sort": "name,asc"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "query": "name==demo", + "sort": "name,asc", + "page": 0, + "size": 25, + } + + +async def test_async_dependency_translates_semantic_errors_to_http_422() -> ( + None +): + """Maps semantic failures to a stable HTTP 422 payload in async routes.""" + app = FastAPI() + dependency = criteria_dependency( + FastAPICriteriaConfig( + query_options=QueryOptions(field_whitelist=frozenset({"name"})), + ), + ) + + @app.get("/items") + async def list_items( + criteria: Annotated[RequestCriteria, Depends(dependency)], + ) -> dict[str, bool]: + return {"is_empty": criteria.is_empty} + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get( + "/items", + params={"filter": "password==demo"}, + ) + + assert response.status_code == 422 + payload = response.json()["detail"] + assert payload["type"] == "query_semantic_error" + assert payload["errors"][0]["code"] == "field_not_whitelisted" + assert payload["errors"][0]["field"] == "password" diff --git a/tests/functional/fastapi_sqlalchemy/test_async_integration.py b/tests/functional/fastapi_sqlalchemy/test_async_integration.py new file mode 100644 index 0000000..86b8c1a --- /dev/null +++ b/tests/functional/fastapi_sqlalchemy/test_async_integration.py @@ -0,0 +1,234 @@ +"""Async functional tests for the FastAPI + SQLAlchemy integration.""" + +from typing import Annotated, Any + +import pytest + +pytest.importorskip("aiosqlite") +pytest.importorskip("fastapi") +from fastapi import Depends, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import ( + async_sessionmaker, + create_async_engine, +) + +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration + +from .conftest import Base, User + +pytestmark = [ + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, + pytest.mark.anyio, +] + + +def _database_url(database_path: object) -> str: + """Builds one SQLite async database URL for a test.""" + return f"sqlite+aiosqlite:///{database_path}" + + +async def _create_engine( + database_path: object, +) -> Any: + """Creates one async SQLAlchemy engine for a test database.""" + engine = create_async_engine(_database_url(database_path)) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + return engine + + +async def _insert_users( + session_factory: async_sessionmaker[Any], + *names: str, +) -> None: + """Inserts test users using one async session factory.""" + async with session_factory() as session: + session.add_all([User(name=name) for name in names]) + await session.commit() + + +def _build_app( + session_factory: async_sessionmaker[Any], +) -> FastAPI: + """Builds one FastAPI app wired to an async SQLAlchemy session.""" + app = FastAPI() + integration = FastAPISQLAlchemyIntegration() + + async def get_session(): + async with session_factory() as session: + yield session + + @app.get("/users") + async def list_users( + statement: Annotated[Any, Depends(integration.select_dependency(User))], + session: Annotated[Any, Depends(get_session)], + ) -> list[str]: + users = (await session.scalars(statement)).all() + return [user.name for user in users] + + @app.get("/users/count") + async def count_users( + statement: Annotated[ + Any, Depends(integration.count_select_dependency(User)) + ], + session: Annotated[Any, Depends(get_session)], + ) -> dict[str, int]: + total = await session.scalar(statement) + assert total is not None + return {"count": total} + + @app.get("/users/paginated") + async def paginated_users( + bundle: Annotated[ + Any, Depends(integration.paginated_select_dependency(User)) + ], + session: Annotated[Any, Depends(get_session)], + ) -> dict[str, object]: + users = (await session.scalars(bundle.statement)).all() + total = await session.scalar(bundle.count_statement) + assert total is not None + return { + "names": [user.name for user in users], + "count": total, + } + + @app.get("/users/resource") + async def resource_users( + statement: Annotated[ + Any, + Depends( + integration.resource( + User, + sortable_fields={"name"}, + default_sort="-name", + ).select_dependency() + ), + ], + session: Annotated[Any, Depends(get_session)], + ) -> list[str]: + users = (await session.scalars(statement)).all() + return [user.name for user in users] + + return app + + +@pytest.mark.parametrize( + ("params", "expected_names"), + [ + pytest.param( + {"filter": "name==demo", "sort": "name,asc"}, + ["demo"], + id="filtered-select", + ), + pytest.param( + {"sort": "name,desc", "size": 2}, + ["zoe", "maria"], + id="sorted-and-paged-select", + ), + ], +) +async def test_async_select_dependency_returns_expected_rows( + tmp_path, + *, + params: dict[str, object], + expected_names: list[str], +) -> None: + """Returns rows through AsyncSession using the select dependency.""" + database_path = tmp_path / "select.db" + engine = await _create_engine(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _insert_users(session_factory, "demo", "maria", "zoe") + app = _build_app(session_factory) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get("/users", params=params) + + assert response.status_code == 200 + assert response.json() == expected_names + finally: + await engine.dispose() + + +async def test_async_count_select_dependency_returns_filtered_count( + tmp_path, +) -> None: + """Returns a filtered count through AsyncSession.""" + database_path = tmp_path / "count.db" + engine = await _create_engine(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _insert_users(session_factory, "demo", "demo", "zoe") + app = _build_app(session_factory) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get( + "/users/count", + params={"filter": "name==demo", "sort": "name,asc"}, + ) + + assert response.status_code == 200 + assert response.json() == {"count": 2} + finally: + await engine.dispose() + + +async def test_async_paginated_select_dependency_returns_rows_and_count( + tmp_path, +) -> None: + """Returns paginated rows and total count through AsyncSession.""" + database_path = tmp_path / "paginated.db" + engine = await _create_engine(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _insert_users(session_factory, "demo", "maria", "zoe") + app = _build_app(session_factory) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get( + "/users/paginated", + params={"sort": "name,asc", "size": 2}, + ) + + assert response.status_code == 200 + assert response.json() == { + "names": ["demo", "maria"], + "count": 3, + } + finally: + await engine.dispose() + + +async def test_async_resource_dependency_applies_default_sort( + tmp_path, +) -> None: + """Applies resource default sort in an async FastAPI route.""" + database_path = tmp_path / "resource.db" + engine = await _create_engine(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _insert_users(session_factory, "demo", "maria", "zoe") + app = _build_app(session_factory) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get("/users/resource") + + assert response.status_code == 200 + assert response.json() == ["zoe", "maria", "demo"] + finally: + await engine.dispose() diff --git a/tests/integration/sqlalchemy/test_async_pipeline.py b/tests/integration/sqlalchemy/test_async_pipeline.py new file mode 100644 index 0000000..94be96f --- /dev/null +++ b/tests/integration/sqlalchemy/test_async_pipeline.py @@ -0,0 +1,104 @@ +"""Async integration tests for SQLAlchemy statement execution.""" + +import pytest + +pytest.importorskip("aiosqlite") +pytest.importorskip("sqlalchemy") +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from tests.functional.fastapi_sqlalchemy.conftest import Base, User + +import pyrsql +from pyrsql.core.page import PageRequest +from pyrsql.core.sort import Sort + +pytestmark = [ + pytest.mark.integration, + pytest.mark.sqlalchemy, + pytest.mark.anyio, +] + + +def _database_url(database_path: object) -> str: + """Builds one SQLite async database URL for a test.""" + return f"sqlite+aiosqlite:///{database_path}" + + +async def _create_schema(database_path: object): + """Creates one async engine and initializes the schema.""" + engine = create_async_engine(_database_url(database_path)) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + return engine + + +async def _seed_users(session_factory: async_sessionmaker, *names: str) -> None: + """Seeds test rows through an AsyncSession.""" + async with session_factory() as session: + session.add_all([User(name=name) for name in names]) + await session.commit() + + +async def test_query_statement_executes_with_async_session( + tmp_path, +) -> None: + """Executes a compiled pyrsql query through AsyncSession.""" + database_path = tmp_path / "query.db" + engine = await _create_schema(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _seed_users(session_factory, "demo", "maria") + orm = pyrsql.orms.sqlalchemy.SQLAlchemyORM() + statement = pyrsql.parse("name==demo").apply( + select(User), + User, + orm=orm, + ) + + async with session_factory() as session: + users = (await session.scalars(statement)).all() + + assert [user.name for user in users] == ["demo"] + finally: + await engine.dispose() + + +async def test_sort_statement_executes_with_async_session( + tmp_path, +) -> None: + """Executes a compiled pyrsql sort through AsyncSession.""" + database_path = tmp_path / "sort.db" + engine = await _create_schema(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _seed_users(session_factory, "demo", "zoe", "maria") + orm = pyrsql.orms.sqlalchemy.SQLAlchemyORM() + statement = Sort.parse("name,desc").apply(select(User), User, orm=orm) + + async with session_factory() as session: + users = (await session.scalars(statement)).all() + + assert [user.name for user in users] == ["zoe", "maria", "demo"] + finally: + await engine.dispose() + + +async def test_page_statement_executes_with_async_session( + tmp_path, +) -> None: + """Executes a compiled pyrsql page request through AsyncSession.""" + database_path = tmp_path / "page.db" + engine = await _create_schema(database_path) + try: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + await _seed_users(session_factory, "ana", "bia", "cai", "dio") + orm = pyrsql.orms.sqlalchemy.SQLAlchemyORM() + statement = Sort.parse("name,asc").apply(select(User), User, orm=orm) + paged_statement = PageRequest.of(1, 2).apply(statement, User, orm=orm) + + async with session_factory() as session: + users = (await session.scalars(paged_statement)).all() + + assert [user.name for user in users] == ["cai", "dio"] + finally: + await engine.dispose() diff --git a/uv.lock b/uv.lock index 91262ff..5608d56 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -1172,6 +1181,7 @@ sqlalchemy = [ [package.dev-dependencies] dev = [ + { name = "aiosqlite" }, { name = "httpx" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -1188,6 +1198,7 @@ lint = [ { name = "ruff" }, ] test = [ + { name = "aiosqlite" }, { name = "httpx" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1207,6 +1218,7 @@ provides-extras = ["sqlalchemy", "fastapi"] [package.metadata.requires-dev] dev = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mkdocs-material", specifier = ">=9.5" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" }, @@ -1221,6 +1233,7 @@ docs = [ ] lint = [{ name = "ruff", specifier = ">=0.11.0" }] test = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", specifier = ">=8.2.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, From 279c69b8654185d4e44065f76e54ff86172b964f Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 19:00:44 -0300 Subject: [PATCH 04/13] refactor: switch dataclasses with msgspec in internal apis --- src/pyrsql/adapters/fastapi/config.py | 25 +++- src/pyrsql/core/conversion.py | 64 +++++--- src/pyrsql/core/field_policy.py | 20 +-- src/pyrsql/core/json/options.py | 24 +-- src/pyrsql/core/json/path.py | 17 ++- src/pyrsql/core/options.py | 137 ++++++++---------- src/pyrsql/core/query.py | 6 +- src/pyrsql/core/sort.py | 6 +- .../fastapi/sqlalchemy/integration.py | 58 +++++++- src/pyrsql/orms/sqlalchemy/json_path.py | 10 +- src/pyrsql/parsing/operators.py | 22 +-- .../unit/adapters/fastapi_adapter/conftest.py | 26 ++-- tests/unit/core/test_conversion.py | 36 +++++ 13 files changed, 274 insertions(+), 177 deletions(-) diff --git a/src/pyrsql/adapters/fastapi/config.py b/src/pyrsql/adapters/fastapi/config.py index c39df3c..4c1fd66 100644 --- a/src/pyrsql/adapters/fastapi/config.py +++ b/src/pyrsql/adapters/fastapi/config.py @@ -19,6 +19,7 @@ class FastAPICriteriaConfig( msgspec.Struct, + frozen=True, gc=False, kw_only=True, ): @@ -98,17 +99,25 @@ def __post_init__(self) -> None: raise TypeError("page_openapi_examples must be a mapping instance.") if not isinstance(self.size_openapi_examples, Mapping): raise TypeError("size_openapi_examples must be a mapping instance.") - self.filter_openapi_examples = MappingProxyType( - dict(self.filter_openapi_examples), + msgspec.structs.force_setattr( + self, + "filter_openapi_examples", + MappingProxyType(dict(self.filter_openapi_examples)), ) - self.sort_openapi_examples = MappingProxyType( - dict(self.sort_openapi_examples), + msgspec.structs.force_setattr( + self, + "sort_openapi_examples", + MappingProxyType(dict(self.sort_openapi_examples)), ) - self.page_openapi_examples = MappingProxyType( - dict(self.page_openapi_examples), + msgspec.structs.force_setattr( + self, + "page_openapi_examples", + MappingProxyType(dict(self.page_openapi_examples)), ) - self.size_openapi_examples = MappingProxyType( - dict(self.size_openapi_examples), + msgspec.structs.force_setattr( + self, + "size_openapi_examples", + MappingProxyType(dict(self.size_openapi_examples)), ) @property diff --git a/src/pyrsql/core/conversion.py b/src/pyrsql/core/conversion.py index 832a8ee..5547139 100644 --- a/src/pyrsql/core/conversion.py +++ b/src/pyrsql/core/conversion.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass import datetime as dt from decimal import Decimal from enum import Enum @@ -17,7 +16,6 @@ from collections.abc import Mapping ValueConverter = Callable[[str], object] -_JSON_DECODER = msgspec.json.Decoder() class ValueConversionError(ValueError): @@ -71,15 +69,38 @@ def _convert_datetime(raw_value: str) -> dt.datetime: return dt.datetime.combine(parsed_date, dt.time.min) -@dataclass(frozen=True, slots=True) -class ValueConverterRegistry: +def _build_msgspec_converter( + target_type: type[Any], +) -> ValueConverter: + """Builds one msgspec-backed scalar converter. + + Returns: + A converter that validates and coerces a string via ``msgspec``. + """ + + def converter(raw_value: str) -> object: + try: + return msgspec.convert( + raw_value, + type=target_type, + strict=False, + ) + except msgspec.ValidationError as error: + raise ValueConversionError( + f"Failed to convert {raw_value!r} to {target_type.__name__}.", + ) from error + + return converter + + +class ValueConverterRegistry(msgspec.Struct, frozen=True, gc=False): """Immutable registry of orm-neutral string-to-type converters.""" converters: Mapping[type[Any], ValueConverter] def __post_init__(self) -> None: """Normalizes the converter mapping into an immutable view.""" - object.__setattr__( + msgspec.structs.force_setattr( self, "converters", MappingProxyType(dict(self.converters)), @@ -216,13 +237,6 @@ def _convert_json_container( ValueConversionError: If the JSON payload is invalid, has the wrong container shape, or cannot be rewrapped into the target type. """ - try: - decoded = _JSON_DECODER.decode(raw_value) - except msgspec.DecodeError as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", - ) from error - expected_type: type[Any] if issubclass(target_type, dict): expected_type = dict @@ -233,10 +247,13 @@ def _convert_json_container( f"Unsupported target type {target_type!r}.", ) - if not isinstance(decoded, expected_type): + try: + decoded = msgspec.json.decode(raw_value, type=expected_type) + except msgspec.ValidationError as error: raise ValueConversionError( f"Failed to convert {raw_value!r} to {target_type.__name__}.", - ) + ) from error + if target_type is expected_type: return decoded try: @@ -247,8 +264,7 @@ def _convert_json_container( ) from error -@dataclass(frozen=True, slots=True) -class FieldValueConverterSet: +class FieldValueConverterSet(msgspec.Struct, frozen=True, gc=False): """Immutable field-scoped value converter configuration.""" field_converters: Mapping[str, ValueConverter] @@ -256,12 +272,12 @@ class FieldValueConverterSet: def __post_init__(self) -> None: """Normalizes nested converter mappings into immutable views.""" - object.__setattr__( + msgspec.structs.force_setattr( self, "field_converters", MappingProxyType(dict(self.field_converters)), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "model_field_converters", MappingProxyType( @@ -298,12 +314,12 @@ def resolve( DEFAULT_VALUE_CONVERTER_REGISTRY = ValueConverterRegistry( { bool: _convert_bool, - int: int, - float: float, - Decimal: Decimal, - UUID: UUID, - dt.date: dt.date.fromisoformat, - dt.time: dt.time.fromisoformat, + int: _build_msgspec_converter(int), + float: _build_msgspec_converter(float), + Decimal: _build_msgspec_converter(Decimal), + UUID: _build_msgspec_converter(UUID), + dt.date: _build_msgspec_converter(dt.date), + dt.time: _build_msgspec_converter(dt.time), dt.datetime: _convert_datetime, }, ) diff --git a/src/pyrsql/core/field_policy.py b/src/pyrsql/core/field_policy.py index 31bbcb1..6cb731e 100644 --- a/src/pyrsql/core/field_policy.py +++ b/src/pyrsql/core/field_policy.py @@ -2,15 +2,15 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING, Any +import msgspec + if TYPE_CHECKING: from collections.abc import Mapping -@dataclass(frozen=True, slots=True) -class FieldPolicySet: +class FieldPolicySet(msgspec.Struct, frozen=True, gc=False): """ORM-neutral field mapping and access-policy configuration. Attributes: @@ -22,6 +22,13 @@ class FieldPolicySet: model_field_blacklist: Model-specific blocked field names. """ + field_mapping: Mapping[str, str] + field_whitelist: frozenset[str] + field_blacklist: frozenset[str] + model_field_mapping: Mapping[type[Any], Mapping[str, str]] + model_field_whitelist: Mapping[type[Any], frozenset[str]] + model_field_blacklist: Mapping[type[Any], frozenset[str]] + @property def is_empty(self) -> bool: """Whether the policy carries no active restrictions.""" @@ -36,13 +43,6 @@ def is_empty(self) -> bool: ), ) - field_mapping: Mapping[str, str] - field_whitelist: frozenset[str] - field_blacklist: frozenset[str] - model_field_mapping: Mapping[type[Any], Mapping[str, str]] - model_field_whitelist: Mapping[type[Any], frozenset[str]] - model_field_blacklist: Mapping[type[Any], frozenset[str]] - def map_model_field( self, model: type[Any], diff --git a/src/pyrsql/core/json/options.py b/src/pyrsql/core/json/options.py index 4cbcb08..ef6366d 100644 --- a/src/pyrsql/core/json/options.py +++ b/src/pyrsql/core/json/options.py @@ -24,7 +24,7 @@ class JSONSortScalarType(str, Enum): DATETIME_TZ = "datetime_tz" -class JSONOptions(msgspec.Struct, gc=False, kw_only=True): +class JSONOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral JSON behavior flags. Attributes: @@ -56,15 +56,19 @@ def __post_init__(self) -> None: ) if not isinstance(self.sort_field_types, Mapping): raise TypeError("sort_field_types must be a mapping.") - self.sort_field_types = MappingProxyType( - { - self._normalize_sort_field_path(path): ( - sort_type - if isinstance(sort_type, JSONSortScalarType) - else JSONSortScalarType(sort_type) - ) - for path, sort_type in self.sort_field_types.items() - }, + msgspec.structs.force_setattr( + self, + "sort_field_types", + MappingProxyType( + { + self._normalize_sort_field_path(path): ( + sort_type + if isinstance(sort_type, JSONSortScalarType) + else JSONSortScalarType(sort_type) + ) + for path, sort_type in self.sort_field_types.items() + }, + ), ) @staticmethod diff --git a/src/pyrsql/core/json/path.py b/src/pyrsql/core/json/path.py index 54f354a..9e5c329 100644 --- a/src/pyrsql/core/json/path.py +++ b/src/pyrsql/core/json/path.py @@ -1,6 +1,5 @@ """Immutable JSON path primitives.""" -import json import re import msgspec @@ -8,7 +7,7 @@ _SIMPLE_JSONPATH_SEGMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -class JSONPath(msgspec.Struct, gc=False, kw_only=True): +class JSONPath(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Represents an ORM-neutral JSON path. Attributes: @@ -32,8 +31,16 @@ def __post_init__(self) -> None: raise ValueError( "JSON path segments must not contain outer whitespace.", ) - self._dot_path = ".".join(self.segments) - self._postgresql_jsonpath = self._build_postgresql_jsonpath() + msgspec.structs.force_setattr( + self, + "_dot_path", + ".".join(self.segments), + ) + msgspec.structs.force_setattr( + self, + "_postgresql_jsonpath", + self._build_postgresql_jsonpath(), + ) @property def is_root(self) -> bool: @@ -82,4 +89,4 @@ def _render_postgresql_segment(segment: str) -> str: """ if _SIMPLE_JSONPATH_SEGMENT_PATTERN.fullmatch(segment) is not None: return f".{segment}" - return "." + json.dumps(segment) + return "." + msgspec.json.encode(segment).decode() diff --git a/src/pyrsql/core/options.py b/src/pyrsql/core/options.py index 3d468cd..e78e695 100644 --- a/src/pyrsql/core/options.py +++ b/src/pyrsql/core/options.py @@ -2,10 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, field from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar +import msgspec + from pyrsql.core.conversion import ( DEFAULT_FIELD_VALUE_CONVERTER_SET, DEFAULT_VALUE_CONVERTER_REGISTRY, @@ -108,47 +109,47 @@ def _normalize_shared_policy_options( options: _SharedPolicyOptionsProtocol, ) -> None: """Normalizes option fields shared by query and sort configuration.""" - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "field_mapping", _normalize_mapping(options.field_mapping), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "model_field_mapping", _normalize_nested_mapping(options.model_field_mapping), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "join_hints", _normalize_mapping(options.join_hints), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "field_whitelist", _normalize_frozenset(options.field_whitelist), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "field_blacklist", _normalize_frozenset(options.field_blacklist), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "model_field_whitelist", _normalize_nested_sets(options.model_field_whitelist), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "model_field_blacklist", _normalize_nested_sets(options.model_field_blacklist), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "procedure_whitelist", _normalize_tuple(options.procedure_whitelist), ) - object.__setattr__( # noqa: PLC2801 + msgspec.structs.force_setattr( options, "procedure_blacklist", _normalize_tuple(options.procedure_blacklist), @@ -206,8 +207,7 @@ def _build_procedure_policy( return ProcedureAccessPolicy.from_patterns(whitelist, blacklist) -@dataclass(frozen=True, slots=True) -class QueryOptions: +class QueryOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral configuration for parsing and compiling queries. The options bundle parser limits, operator registries, field policies, @@ -218,84 +218,70 @@ class QueryOptions: strict_equality: bool = False distinct: bool = False like_escape_character: str | None = None - field_mapping: Mapping[str, str] = field(default_factory=dict) - model_field_mapping: Mapping[type[Any], Mapping[str, str]] = field( - default_factory=dict, - ) - join_hints: Mapping[str, JoinHint] = field(default_factory=dict) - field_whitelist: frozenset[str] = field(default_factory=frozenset) - field_blacklist: frozenset[str] = field(default_factory=frozenset) - model_field_whitelist: Mapping[type[Any], frozenset[str]] = field( - default_factory=dict, - ) - model_field_blacklist: Mapping[type[Any], frozenset[str]] = field( - default_factory=dict, - ) + field_mapping: Mapping[str, str] = {} + model_field_mapping: Mapping[type[Any], Mapping[str, str]] = {} + join_hints: Mapping[str, JoinHint] = {} + field_whitelist: frozenset[str] = frozenset() + field_blacklist: frozenset[str] = frozenset() + model_field_whitelist: Mapping[type[Any], frozenset[str]] = {} + model_field_blacklist: Mapping[type[Any], frozenset[str]] = {} procedure_whitelist: tuple[str, ...] = () procedure_blacklist: tuple[str, ...] = () parse_limits: ParseLimits = DEFAULT_PARSE_LIMITS operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY - custom_predicates: Mapping[str, CustomPredicateDefinition] = field( - default_factory=dict, - ) + custom_predicates: Mapping[str, CustomPredicateDefinition] = {} value_converter_registry: ValueConverterRegistry = ( DEFAULT_VALUE_CONVERTER_REGISTRY ) - field_value_converters: Mapping[str, ValueConverter] = field( - default_factory=dict, - ) + field_value_converters: Mapping[str, ValueConverter] = {} model_field_value_converters: Mapping[ type[Any], Mapping[str, ValueConverter], - ] = field(default_factory=dict) - json_options: JSONOptions = DEFAULT_JSON_OPTIONS - _field_policy: FieldPolicySet = field( - init=False, - repr=False, - compare=False, + ] = {} + json_options: JSONOptions = msgspec.field( + default_factory=lambda: DEFAULT_JSON_OPTIONS, ) - _field_converter_set: FieldValueConverterSet = field( - init=False, - repr=False, - compare=False, - ) - _procedure_policy: ProcedureAccessPolicy = field( - init=False, - repr=False, - compare=False, + _field_policy: FieldPolicySet = DEFAULT_FIELD_POLICY_SET + _field_converter_set: FieldValueConverterSet = ( + DEFAULT_FIELD_VALUE_CONVERTER_SET ) + _procedure_policy: ProcedureAccessPolicy = DEFAULT_PROCEDURE_ACCESS_POLICY def __post_init__(self) -> None: """Normalizes option containers into immutable representations.""" _normalize_shared_policy_options(self) - object.__setattr__( + msgspec.structs.force_setattr( self, "field_value_converters", _normalize_mapping(self.field_value_converters), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "model_field_value_converters", _normalize_nested_mapping(self.model_field_value_converters), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "custom_predicates", _normalize_mapping(self.custom_predicates), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "operator_registry", self._build_operator_registry(), ) _validate_like_escape_character(self.like_escape_character) - object.__setattr__(self, "_field_policy", self._build_field_policy()) - object.__setattr__( + msgspec.structs.force_setattr( + self, + "_field_policy", + self._build_field_policy(), + ) + msgspec.structs.force_setattr( self, "_field_converter_set", self._build_field_converter_set(), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "_procedure_policy", self._build_procedure_policy(), @@ -419,43 +405,34 @@ def _build_procedure_policy(self) -> ProcedureAccessPolicy: ) -@dataclass(frozen=True, slots=True) -class SortOptions: +class SortOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral sort options.""" - field_mapping: Mapping[str, str] = field(default_factory=dict) - model_field_mapping: Mapping[type[Any], Mapping[str, str]] = field( - default_factory=dict, - ) - join_hints: Mapping[str, JoinHint] = field(default_factory=dict) - field_whitelist: frozenset[str] = field(default_factory=frozenset) - field_blacklist: frozenset[str] = field(default_factory=frozenset) - model_field_whitelist: Mapping[type[Any], frozenset[str]] = field( - default_factory=dict, - ) - model_field_blacklist: Mapping[type[Any], frozenset[str]] = field( - default_factory=dict, - ) + field_mapping: Mapping[str, str] = {} + model_field_mapping: Mapping[type[Any], Mapping[str, str]] = {} + join_hints: Mapping[str, JoinHint] = {} + field_whitelist: frozenset[str] = frozenset() + field_blacklist: frozenset[str] = frozenset() + model_field_whitelist: Mapping[type[Any], frozenset[str]] = {} + model_field_blacklist: Mapping[type[Any], frozenset[str]] = {} procedure_whitelist: tuple[str, ...] = () procedure_blacklist: tuple[str, ...] = () sort_limits: SortLimits = DEFAULT_SORT_LIMITS - json_options: JSONOptions = DEFAULT_JSON_OPTIONS - _field_policy: FieldPolicySet = field( - init=False, - repr=False, - compare=False, - ) - _procedure_policy: ProcedureAccessPolicy = field( - init=False, - repr=False, - compare=False, + json_options: JSONOptions = msgspec.field( + default_factory=lambda: DEFAULT_JSON_OPTIONS, ) + _field_policy: FieldPolicySet = DEFAULT_FIELD_POLICY_SET + _procedure_policy: ProcedureAccessPolicy = DEFAULT_PROCEDURE_ACCESS_POLICY def __post_init__(self) -> None: """Normalizes option containers into immutable representations.""" _normalize_shared_policy_options(self) - object.__setattr__(self, "_field_policy", self._build_field_policy()) - object.__setattr__( + msgspec.structs.force_setattr( + self, + "_field_policy", + self._build_field_policy(), + ) + msgspec.structs.force_setattr( self, "_procedure_policy", self._build_procedure_policy(), diff --git a/src/pyrsql/core/query.py b/src/pyrsql/core/query.py index dc7ac43..66fac52 100644 --- a/src/pyrsql/core/query.py +++ b/src/pyrsql/core/query.py @@ -2,9 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar +import msgspec + from pyrsql.core.compiler import CompilationResult from pyrsql.core.options import QueryOptions from pyrsql.parsing.parser import Parser @@ -62,8 +63,7 @@ def _bind_query_expression( return SemanticBinder(options).bind(expression) -@dataclass(frozen=True, slots=True) -class Query: +class Query(msgspec.Struct, frozen=True, gc=False): """Represents a parsed ORM-neutral query request. The query preserves the raw text, the parsed expression tree, and the diff --git a/src/pyrsql/core/sort.py b/src/pyrsql/core/sort.py index 2a274df..bae86f4 100644 --- a/src/pyrsql/core/sort.py +++ b/src/pyrsql/core/sort.py @@ -2,9 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar +import msgspec + from pyrsql.core.compiler import SortCompilationResult from pyrsql.core.options import SortOptions from pyrsql.sorting.binder import SortBinder @@ -63,8 +64,7 @@ def _bind_sort_fields( return SortBinder(options).bind(fields) -@dataclass(frozen=True, slots=True) -class Sort: +class Sort(msgspec.Struct, frozen=True, gc=False): """Represents an ORM-neutral parsed sort request. Attributes: diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index e3d35f4..687febd 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import replace from threading import RLock from types import MappingProxyType from typing import TYPE_CHECKING @@ -14,6 +13,7 @@ CriteriaDependency, FastAPICriteriaConfig, ) +from pyrsql.core.options import QueryOptions, SortOptions from pyrsql.core.sort import Sort as PyrsqlSort from pyrsql.orms.sqlalchemy import SQLAlchemyORM from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select @@ -108,6 +108,54 @@ def criteria_dependency(self) -> CriteriaDependency: """ return self._criteria_dependency + @staticmethod + def _with_query_field_whitelist( + options: QueryOptions, + field_whitelist: frozenset[str], + ) -> QueryOptions: + """Returns query options overriding only the field whitelist.""" + return QueryOptions( + strict_equality=options.strict_equality, + distinct=options.distinct, + like_escape_character=options.like_escape_character, + field_mapping=options.field_mapping, + model_field_mapping=options.model_field_mapping, + join_hints=options.join_hints, + field_whitelist=field_whitelist, + field_blacklist=options.field_blacklist, + model_field_whitelist=options.model_field_whitelist, + model_field_blacklist=options.model_field_blacklist, + procedure_whitelist=options.procedure_whitelist, + procedure_blacklist=options.procedure_blacklist, + parse_limits=options.parse_limits, + operator_registry=options.operator_registry, + custom_predicates=options.custom_predicates, + value_converter_registry=options.value_converter_registry, + field_value_converters=options.field_value_converters, + model_field_value_converters=(options.model_field_value_converters), + json_options=options.json_options, + ) + + @staticmethod + def _with_sort_field_whitelist( + options: SortOptions, + field_whitelist: frozenset[str], + ) -> SortOptions: + """Returns sort options overriding only the field whitelist.""" + return SortOptions( + field_mapping=options.field_mapping, + model_field_mapping=options.model_field_mapping, + join_hints=options.join_hints, + field_whitelist=field_whitelist, + field_blacklist=options.field_blacklist, + model_field_whitelist=options.model_field_whitelist, + model_field_blacklist=options.model_field_blacklist, + procedure_whitelist=options.procedure_whitelist, + procedure_blacklist=options.procedure_blacklist, + sort_limits=options.sort_limits, + json_options=options.json_options, + ) + def _apply_query( self, statement: SQLAlchemySelect, @@ -353,14 +401,14 @@ def resource( query_options = self.criteria_config.query_options sort_options = self.criteria_config.sort_options if filterable_fields is not None: - query_options = replace( + query_options = self._with_query_field_whitelist( query_options, - field_whitelist=frozenset(filterable_fields), + frozenset(filterable_fields), ) if sortable_fields is not None: - sort_options = replace( + sort_options = self._with_sort_field_whitelist( sort_options, - field_whitelist=frozenset(sortable_fields), + frozenset(sortable_fields), ) filter_openapi_examples = merge_openapi_examples( diff --git a/src/pyrsql/orms/sqlalchemy/json_path.py b/src/pyrsql/orms/sqlalchemy/json_path.py index 755e1be..b2dd752 100644 --- a/src/pyrsql/orms/sqlalchemy/json_path.py +++ b/src/pyrsql/orms/sqlalchemy/json_path.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import re from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -555,7 +554,7 @@ def _regex_comparison( if "*" not in normalized: normalized = f"*{normalized}*" pattern = re.escape(normalized).replace(r"\*", ".*") - literal = json.dumps(pattern) + literal = msgspec.json.encode(pattern).decode() if ignore_case: return f'(@ like_regex {literal} flag "i")' return f"(@ like_regex {literal})" @@ -640,6 +639,7 @@ def _is_timezone_datetime_value(value: JSONScalarValue) -> bool: class _JSONPathFilterCall( msgspec.Struct, + frozen=True, gc=False, kw_only=True, ): @@ -652,4 +652,8 @@ class _JSONPathFilterCall( def __post_init__(self) -> None: """Normalizes vars payload into an immutable mapping.""" - self.vars_payload = MappingProxyType(dict(self.vars_payload)) + msgspec.structs.force_setattr( + self, + "vars_payload", + MappingProxyType(dict(self.vars_payload)), + ) diff --git a/src/pyrsql/parsing/operators.py b/src/pyrsql/parsing/operators.py index dc8983c..fb8b26c 100644 --- a/src/pyrsql/parsing/operators.py +++ b/src/pyrsql/parsing/operators.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass, field from types import MappingProxyType from typing import TYPE_CHECKING @@ -45,15 +44,16 @@ def __post_init__(self) -> None: ) -@dataclass(frozen=True, slots=True) -class OperatorRegistry: +class OperatorRegistry(msgspec.Struct, frozen=True, gc=False): """Immutable registry of supported comparison operators.""" - operators: tuple[ComparisonOperator, ...] = field(default_factory=tuple) - operators_by_spelling: Mapping[str, ComparisonOperator] = field(init=False) - operator_spellings: tuple[str, ...] = field(init=False) - operator_spellings_by_prefix: Mapping[str, tuple[str, ...]] = field( - init=False, + operators: tuple[ComparisonOperator, ...] = () + operators_by_spelling: Mapping[str, ComparisonOperator] = MappingProxyType( + {} + ) + operator_spellings: tuple[str, ...] = () + operator_spellings_by_prefix: Mapping[str, tuple[str, ...]] = ( + MappingProxyType({}) ) def __post_init__(self) -> None: @@ -75,12 +75,12 @@ def __post_init__(self) -> None: spellings_by_prefix: dict[str, list[str]] = {} for spelling in operators_by_spelling: spellings_by_prefix.setdefault(spelling[0], []).append(spelling) - object.__setattr__( + msgspec.structs.force_setattr( self, "operators_by_spelling", MappingProxyType(operators_by_spelling), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "operator_spellings", tuple( @@ -91,7 +91,7 @@ def __post_init__(self) -> None: ), ), ) - object.__setattr__( + msgspec.structs.force_setattr( self, "operator_spellings_by_prefix", MappingProxyType( diff --git a/tests/unit/adapters/fastapi_adapter/conftest.py b/tests/unit/adapters/fastapi_adapter/conftest.py index 92bbb89..138258e 100644 --- a/tests/unit/adapters/fastapi_adapter/conftest.py +++ b/tests/unit/adapters/fastapi_adapter/conftest.py @@ -22,27 +22,23 @@ @pytest.fixture def query_stub() -> Query: """Provides a minimal Query instance without invoking parsing.""" - query = object.__new__(Query) - object.__setattr__(query, "text", "stub") # noqa: PLC2801 - object.__setattr__(query, "options", QueryOptions()) # noqa: PLC2801 - object.__setattr__(query, "expression", cast("Expression", object())) # noqa: PLC2801 - object.__setattr__( # noqa: PLC2801 - query, - "bound_expression", - cast("BoundComparison", object()), + return Query( + text="stub", + options=QueryOptions(), + expression=cast("Expression", object()), + bound_expression=cast("BoundComparison", object()), ) - return query @pytest.fixture def sort_stub() -> Sort: """Provides a minimal Sort instance without invoking parsing.""" - sort = object.__new__(Sort) - object.__setattr__(sort, "text", "name,asc") # noqa: PLC2801 - object.__setattr__(sort, "options", SortOptions()) # noqa: PLC2801 - object.__setattr__(sort, "fields", ()) # noqa: PLC2801 - object.__setattr__(sort, "bound_sort", None) # noqa: PLC2801 - return sort + return Sort( + text="name,asc", + options=SortOptions(), + fields=(), + bound_sort=None, + ) @pytest.fixture diff --git a/tests/unit/core/test_conversion.py b/tests/unit/core/test_conversion.py index 5fecfe7..5cde9e9 100644 --- a/tests/unit/core/test_conversion.py +++ b/tests/unit/core/test_conversion.py @@ -31,6 +31,14 @@ class CustomIdentifier: value: str +class StringList(list[str]): # noqa: FURB189 + """List subclass used to verify container rewrapping.""" + + +class StringMap(dict[str, str]): # noqa: FURB189 + """Dict subclass used to verify container rewrapping.""" + + @pytest.mark.parametrize( ("raw_value", "target_type", "expected"), [ @@ -147,6 +155,34 @@ def test_default_registry_converts_json_container_types( ) +@pytest.mark.parametrize( + ("raw_value", "target_type", "expected_type"), + [ + pytest.param( + '["a","b","c"]', + StringList, + StringList, + id="list-subclass", + ), + pytest.param( + '{"kind":"demo","count":"2"}', + StringMap, + StringMap, + id="dict-subclass", + ), + ], +) +def test_default_registry_rewraps_json_container_subclasses( + raw_value: str, + target_type: type[object], + expected_type: type[object], +) -> None: + """Rewraps decoded builtin JSON containers into requested subclasses.""" + converted = DEFAULT_VALUE_CONVERTER_REGISTRY.convert(raw_value, target_type) + + assert isinstance(converted, expected_type) + + @pytest.mark.parametrize( ("raw_value", "target_type"), [ From 753d09ba9571af4b307dbb816f8288cd4dfb3722 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 19:35:20 -0300 Subject: [PATCH 05/13] refactor: implements security tests --- pyproject.toml | 1 + .../fastapi/sqlalchemy/helpers.py | 106 +++++++++ .../fastapi/sqlalchemy/integration.py | 41 +++- .../fastapi/sqlalchemy/resource.py | 49 +++- tests/security/__init__.py | 1 + tests/security/conftest.py | 109 +++++++++ .../security/test_configuration_hardening.py | 30 +++ ...t_fastapi_sqlalchemy_error_sanitization.py | 99 ++++++++ tests/security/test_fastapi_sqlalchemy_hpp.py | 83 +++++++ .../test_fastapi_sqlalchemy_injection.py | 49 ++++ .../test_fastapi_sqlalchemy_limits.py | 212 ++++++++++++++++++ .../test_fastapi_sqlalchemy_policies.py | 180 +++++++++++++++ ...st_fastapi_sqlalchemy_resource_security.py | 51 +++++ tests/security/test_sqlalchemy_injection.py | 77 +++++++ .../test_sqlalchemy_jsonpath_security.py | 70 ++++++ 15 files changed, 1152 insertions(+), 6 deletions(-) create mode 100644 tests/security/__init__.py create mode 100644 tests/security/conftest.py create mode 100644 tests/security/test_configuration_hardening.py create mode 100644 tests/security/test_fastapi_sqlalchemy_error_sanitization.py create mode 100644 tests/security/test_fastapi_sqlalchemy_hpp.py create mode 100644 tests/security/test_fastapi_sqlalchemy_injection.py create mode 100644 tests/security/test_fastapi_sqlalchemy_limits.py create mode 100644 tests/security/test_fastapi_sqlalchemy_policies.py create mode 100644 tests/security/test_fastapi_sqlalchemy_resource_security.py create mode 100644 tests/security/test_sqlalchemy_injection.py create mode 100644 tests/security/test_sqlalchemy_jsonpath_security.py diff --git a/pyproject.toml b/pyproject.toml index a7755cb..d60dbba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,6 +183,7 @@ markers = [ "unit: isolated tests", "integration: interaction between components", "functional: user-visible behavior", + "security: adversarial safety and injection-resistance tests", "performance: benchmark tests", "sqlalchemy: SQLAlchemy tests", "fastapi: FastAPI tests", diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py index f449db2..60a185e 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py @@ -2,17 +2,123 @@ from __future__ import annotations +from contextlib import contextmanager from typing import TYPE_CHECKING +from fastapi import HTTPException +import msgspec from sqlalchemy import func, select from pyrsql.adapters.fastapi import RequestCriteria +from pyrsql.adapters.fastapi.errors import ( + FastAPIAdapterErrorDetail, + FastAPIAdapterErrorPayload, +) +from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError if TYPE_CHECKING: + from collections.abc import Iterator + from typing import NoReturn + + from pyrsql.adapters.fastapi import FastAPICriteriaConfig from pyrsql.orms.sqlalchemy import SQLAlchemyORM from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect +def _extract_field_from_message(message: str) -> str | None: + """Extracts a quoted field path from backend error messages. + + Returns: + The extracted field path, or ``None`` when no quoted field exists. + """ + quote_start = message.find("'") + if quote_start < 0: + return None + quote_end = message.find("'", quote_start + 1) + if quote_end < 0: + return None + field = message[quote_start + 1 : quote_end] + return field or None + + +def _raise_backend_http_error( + *, + parameter_name: str, + error_type: str, + error: SQLAlchemyORMError, +) -> NoReturn: + """Raises one normalized HTTP 422 error for backend integration failures. + + Raises: + HTTPException: Always raised with a normalized error payload. + """ + payload = FastAPIAdapterErrorPayload( + parameter=parameter_name, + error_type=error_type, + details=( + FastAPIAdapterErrorDetail( + code=error_type, + message=str(error), + field=_extract_field_from_message(str(error)), + ), + ), + ) + raise HTTPException( + status_code=422, + detail={ + "parameter": payload.parameter, + "type": payload.error_type, + "errors": msgspec.to_builtins(payload.details), + }, + ) + + +def raise_query_backend_http_error( + config: "FastAPICriteriaConfig", # noqa: UP037 + error: SQLAlchemyORMError, +) -> NoReturn: + """Raises one normalized HTTP 422 query error for backend failures.""" + _raise_backend_http_error( + parameter_name=config.filter_parameter, + error_type="query_backend_error", + error=error, + ) + + +def raise_sort_backend_http_error( + config: "FastAPICriteriaConfig", # noqa: UP037 + error: SQLAlchemyORMError, +) -> NoReturn: + """Raises one normalized HTTP 422 sort error for backend failures.""" + _raise_backend_http_error( + parameter_name=config.sort_parameter, + error_type="sort_backend_error", + error=error, + ) + + +@contextmanager +def query_backend_http_errors( + config: "FastAPICriteriaConfig", # noqa: UP037 +) -> Iterator[None]: + """Translates backend query failures into normalized HTTP 422 errors.""" + try: + yield + except SQLAlchemyORMError as error: + raise_query_backend_http_error(config, error) + + +@contextmanager +def sort_backend_http_errors( + config: "FastAPICriteriaConfig", # noqa: UP037 +) -> Iterator[None]: + """Translates backend sort failures into normalized HTTP 422 errors.""" + try: + yield + except SQLAlchemyORMError as error: + raise_sort_backend_http_error(config, error) + + def require_request_criteria(criteria: RequestCriteria) -> RequestCriteria: """Validates and returns request criteria objects. diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index 687febd..9df2000 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -28,7 +28,9 @@ apply_query_with_orm, apply_sort_and_page_with_orm, count_from_filtered_select, + query_backend_http_errors, require_request_criteria, + sort_backend_http_errors, ) from .payloads import SQLAlchemyPaginatedSelect from .resource import FastAPISQLAlchemyResource @@ -326,7 +328,18 @@ def select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - return self.select(model, criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + validated_criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return self._apply_sort_and_page( + filtered_statement, + model, + validated_criteria, + ) self._select_dependencies[model] = dependency return dependency @@ -349,7 +362,13 @@ def count_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - return self.count_select(model, criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + validated_criteria, + ) + return self._count_from_filtered_select(filtered_statement) self._count_select_dependencies[model] = dependency return dependency @@ -372,7 +391,23 @@ def paginated_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemyPaginatedSelect: - return self.paginated_select(model, criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + validated_criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return SQLAlchemyPaginatedSelect( + statement=self._apply_sort_and_page( + filtered_statement, + model, + validated_criteria, + ), + count_statement=self._count_from_filtered_select( + filtered_statement, + ), + ) self._paginated_select_dependencies[model] = dependency return dependency diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py index be21d90..c4cb608 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py @@ -19,7 +19,9 @@ apply_query_with_orm, apply_sort_and_page_with_orm, count_from_filtered_select, + query_backend_http_errors, require_request_criteria, + sort_backend_http_errors, ) from .payloads import SQLAlchemyPaginatedSelect @@ -266,7 +268,21 @@ def select_dependency(self) -> Callable[..., SQLAlchemySelect]: def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - return self.select(criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = apply_query_with_orm( + self._base_statement(), + self.model, + validated_criteria, + self.integration.orm, + ) + with sort_backend_http_errors(self.criteria_config): + return apply_sort_and_page_with_orm( + filtered_statement, + self.model, + validated_criteria, + self.integration.orm, + ) self._select_dependency = dependency return dependency @@ -306,7 +322,15 @@ def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - return self.count_select(criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = apply_query_with_orm( + self._base_statement(), + self.model, + validated_criteria, + self.integration.orm, + ) + return count_from_filtered_select(filtered_statement) self._count_select_dependency = dependency return dependency @@ -327,7 +351,26 @@ def paginated_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemyPaginatedSelect: - return self.paginated_select(criteria) + validated_criteria = require_request_criteria(criteria) + with query_backend_http_errors(self.criteria_config): + filtered_statement = apply_query_with_orm( + self._base_statement(), + self.model, + validated_criteria, + self.integration.orm, + ) + with sort_backend_http_errors(self.criteria_config): + return SQLAlchemyPaginatedSelect( + statement=apply_sort_and_page_with_orm( + filtered_statement, + self.model, + validated_criteria, + self.integration.orm, + ), + count_statement=count_from_filtered_select( + filtered_statement, + ), + ) self._paginated_select_dependency = dependency return dependency diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..ec33a68 --- /dev/null +++ b/tests/security/__init__.py @@ -0,0 +1 @@ +"""Security tests.""" diff --git a/tests/security/conftest.py b/tests/security/conftest.py new file mode 100644 index 0000000..db9619d --- /dev/null +++ b/tests/security/conftest.py @@ -0,0 +1,109 @@ +"""Shared fixtures and helpers for security-focused tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +pytest.importorskip("fastapi") +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from tests.functional.fastapi_sqlalchemy.conftest import User + +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration + +if TYPE_CHECKING: + from collections.abc import Callable + + from pyrsql.adapters.fastapi import FastAPICriteriaConfig + + +@pytest.fixture +def integration_app_factory() -> Callable[..., TestClient]: + """Builds lightweight FastAPI apps for security validation.""" + + def build_client( + *, + criteria_config: FastAPICriteriaConfig | None = None, + ) -> TestClient: + app = FastAPI() + integration = FastAPISQLAlchemyIntegration( + criteria_config=criteria_config, + ) + + @app.get("/users") + def list_users( + statement: Any = Depends( # noqa: FAST002 + integration.select_dependency(User), + ), + ) -> dict[str, str]: + return {"sql": str(statement.compile())} + + return TestClient(app, raise_server_exceptions=False) + + return build_client + + +@pytest.fixture +def compiled_params_app_factory() -> Callable[..., TestClient]: + """Builds apps that expose compiled SQL params for deterministic checks.""" + + def build_client( + *, + criteria_config: FastAPICriteriaConfig | None = None, + ) -> TestClient: + app = FastAPI() + integration = FastAPISQLAlchemyIntegration( + criteria_config=criteria_config, + ) + + @app.get("/users") + def list_users( + statement: Any = Depends( # noqa: FAST002 + integration.select_dependency(User), + ), + ) -> dict[str, object]: + compiled = statement.compile() + return {"params": compiled.params} + + return TestClient(app, raise_server_exceptions=False) + + return build_client + + +@pytest.fixture +def resource_app_factory() -> Callable[..., TestClient]: + """Builds apps backed by declarative FastAPI SQLAlchemy resources.""" + + def build_client( + *, + filterable_fields: set[str] | frozenset[str] | None = None, + sortable_fields: set[str] | frozenset[str] | None = None, + ) -> TestClient: + app = FastAPI() + integration = FastAPISQLAlchemyIntegration() + resource = integration.resource( + User, + filterable_fields=filterable_fields, + sortable_fields=sortable_fields, + ) + + @app.get("/users") + def list_users( + statement: Any = Depends( # noqa: FAST002 + resource.select_dependency(), + ), + ) -> dict[str, str]: + return {"sql": str(statement.compile())} + + return TestClient(app, raise_server_exceptions=False) + + return build_client + + +def assert_response_hides_internal_error_details(response: Any) -> None: + """Asserts that one HTTP response does not leak internal error details.""" + assert "Traceback" not in response.text + assert "sqlalchemy." not in response.text + assert "/home/lucas/" not in response.text diff --git a/tests/security/test_configuration_hardening.py b/tests/security/test_configuration_hardening.py new file mode 100644 index 0000000..9afa279 --- /dev/null +++ b/tests/security/test_configuration_hardening.py @@ -0,0 +1,30 @@ +"""Security tests for configuration-level hardening guarantees.""" + +from __future__ import annotations + +import pytest + +from pyrsql.core.json.options import JSONOptions +from pyrsql.core.options import QueryOptions, SortOptions + +pytestmark = [pytest.mark.security] + + +def test_json_options_reject_path_exists_tz_function_injection() -> None: + """Rejects unsafe timezone-aware JSON path function names.""" + with pytest.raises(ValueError, match="valid SQL identifier"): + JSONOptions(path_exists_tz_function="jsonb_path_exists_tz;DROP") + + +def test_query_procedure_whitelist_requires_full_function_match() -> None: + """Does not treat partial procedure-name matches as whitelisted.""" + options = QueryOptions(procedure_whitelist=("upper",)) + + assert options.procedure_policy.is_whitelisted("upper_suffix") is False + + +def test_sort_procedure_whitelist_requires_full_function_match() -> None: + """Applies full regex matching to sort procedure allowlists as well.""" + options = SortOptions(procedure_whitelist=("upper",)) + + assert options.procedure_policy.is_whitelisted("upper_suffix") is False diff --git a/tests/security/test_fastapi_sqlalchemy_error_sanitization.py b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py new file mode 100644 index 0000000..262145c --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py @@ -0,0 +1,99 @@ +"""Security tests for non-verbose error handling at the API edge.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from tests.security.conftest import assert_response_hides_internal_error_details + +from pyrsql.adapters.fastapi import FastAPICriteriaConfig +from pyrsql.core.options import QueryOptions, SortOptions +from pyrsql.parsing.limits import ParseLimits +from pyrsql.sorting.limits import SortLimits + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_backend_query_errors_do_not_leak_stack_traces( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Returns one controlled 422 payload instead of verbose backend errors.""" + response = integration_app_factory().get( + "/users", + params={"filter": "password==demo"}, + ) + + assert response.status_code == 422 + assert_response_hides_internal_error_details(response) + + +def test_backend_sort_errors_do_not_leak_stack_traces( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Returns one controlled 422 payload for invalid sort backend errors.""" + response = integration_app_factory().get( + "/users", + params={"sort": "DROP TABLE users"}, + ) + + assert response.status_code == 422 + assert_response_hides_internal_error_details(response) + + +def test_query_parse_errors_do_not_leak_internal_details( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Keeps parse failures structured without exposing internal paths.""" + response = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_query_length=9), + ), + ), + ).get( + "/users", + params={"filter": "name=='demo'"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert_response_hides_internal_error_details(response) + + +def test_composed_hostile_request_fails_cleanly_at_first_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Fails cleanly on the earliest query limit even with multiple attacks.""" + response = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_query_length=20), + ), + sort_options=SortOptions( + sort_limits=SortLimits(max_sort_length=5), + ), + max_page_size=5, + ), + ).get( + "/users", + params={ + "filter": "name==demo;company.name==acme", + "sort": "company.name,desc", + "size": "5", + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert_response_hides_internal_error_details(response) diff --git a/tests/security/test_fastapi_sqlalchemy_hpp.py b/tests/security/test_fastapi_sqlalchemy_hpp.py new file mode 100644 index 0000000..820bc26 --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_hpp.py @@ -0,0 +1,83 @@ +"""Security tests for duplicate-parameter handling at the API edge.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from pyrsql.adapters.fastapi import FastAPICriteriaConfig + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_duplicate_filter_parameters_use_last_value_and_still_validate( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Applies one stable duplicate-parameter rule instead of concatenation.""" + response = integration_app_factory().get( + "/users", + params=[("filter", "name==demo"), ("filter", "password==x")], + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_backend_error" + assert response.json()["detail"]["errors"][0]["field"] == "password" + + +def test_duplicate_sort_parameters_use_last_value_and_still_validate( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects a malicious last sort value instead of merging both clauses.""" + response = integration_app_factory().get( + "/users", + params=[("sort", "name,asc"), ("sort", "DROP TABLE users")], + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_backend_error" + assert response.json()["detail"]["errors"][0]["field"] == ( + "DROP TABLE users" + ) + + +def test_duplicate_page_parameters_use_last_value_consistently( + compiled_params_app_factory: Callable[..., TestClient], +) -> None: + """Uses a deterministic last-value rule for duplicate page params.""" + client = compiled_params_app_factory( + criteria_config=FastAPICriteriaConfig(max_page_size=5), + ) + + response = client.get( + "/users", + params=[("page", "1"), ("page", "999"), ("size", "2")], + ) + + assert response.status_code == 200 + assert response.json()["params"]["param_2"] == 1998 + + +def test_duplicate_size_parameters_keep_max_page_size_validation( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Still enforces page-size bounds when the last duplicate is unsafe.""" + response = integration_app_factory( + criteria_config=FastAPICriteriaConfig(max_page_size=5), + ).get( + "/users", + params=[("size", "1"), ("size", "100000")], + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "size"] diff --git a/tests/security/test_fastapi_sqlalchemy_injection.py b/tests/security/test_fastapi_sqlalchemy_injection.py new file mode 100644 index 0000000..1ed389e --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_injection.py @@ -0,0 +1,49 @@ +"""Security tests for FastAPI + SQLAlchemy integration resistance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_integration_returns_http_422_for_malicious_sort_field( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Translates malicious backend sort resolution failures into HTTP 422.""" + response = integration_app_factory().get( + "/users", + params={"sort": "name,asc;DROP TABLE users"}, + ) + + assert response.status_code == 422 + assert '"parameter":"sort"' in response.text + assert '"type":"sort_backend_error"' in response.text + assert '"field":"DROP TABLE users"' in response.text + + +def test_integration_returns_http_422_for_malicious_filter_field( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Translates backend field resolution failures into HTTP 422.""" + response = integration_app_factory().get( + "/users", + params={"filter": "password==demo"}, + ) + + assert response.status_code == 422 + assert '"parameter":"filter"' in response.text + assert '"type":"query_backend_error"' in response.text + assert '"field":"password"' in response.text diff --git a/tests/security/test_fastapi_sqlalchemy_limits.py b/tests/security/test_fastapi_sqlalchemy_limits.py new file mode 100644 index 0000000..1c839e9 --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_limits.py @@ -0,0 +1,212 @@ +"""Security tests for parser and paging limits at the FastAPI boundary.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from pyrsql.adapters.fastapi import FastAPICriteriaConfig +from pyrsql.core.options import QueryOptions, SortOptions +from pyrsql.parsing.limits import ParseLimits +from pyrsql.sorting.limits import SortLimits + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_integration_rejects_filter_exceeding_query_length_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects oversized filter input before it reaches ORM translation.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_query_length=9), + ), + ), + ) + + response = client.get("/users", params={"filter": "name=='demo'"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "lex_error" + + +def test_integration_rejects_filter_exceeding_selector_length_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects selectors that exceed the configured maximum field length.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_selector_length=4), + ), + ), + ) + + response = client.get("/users", params={"filter": "company.name==demo"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "lex_error" + + +def test_integration_rejects_filter_exceeding_argument_length_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects one argument whose raw text exceeds the configured limit.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_argument_length=3), + ), + ), + ) + + response = client.get("/users", params={"filter": "name=='demo'"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "lex_error" + + +def test_integration_rejects_filter_with_too_many_list_arguments( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects list filters that exceed the configured item count.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_arguments_per_list=2), + ), + ), + ) + + response = client.get("/users", params={"filter": "id=in=(1,2,3)"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "parse_error" + + +def test_integration_rejects_filter_exceeding_expression_depth_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects deeply nested filter expressions that exceed depth limits.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_expression_depth=2), + ), + ), + ) + + response = client.get("/users", params={"filter": "((name==demo))"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "parse_error" + + +def test_integration_rejects_filter_exceeding_node_count_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects filters that exceed the configured AST node budget.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + parse_limits=ParseLimits(max_node_count=2), + ), + ), + ) + + response = client.get( + "/users", + params={"filter": "name==demo;company.name==acme"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "parse_error" + + +def test_integration_rejects_sort_exceeding_total_length_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects oversized sort clauses before they reach ORM resolution.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions( + sort_limits=SortLimits(max_sort_length=5), + ), + ), + ) + + response = client.get("/users", params={"sort": "company.name,desc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" + + +def test_integration_rejects_sort_with_too_many_fields( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects sort clauses that exceed the configured field count limit.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions( + sort_limits=SortLimits(max_fields=1), + ), + ), + ) + + response = client.get("/users", params={"sort": "name;company.name"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" + + +def test_integration_rejects_sort_exceeding_field_path_length_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects one sort field whose selector exceeds the configured limit.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions( + sort_limits=SortLimits(max_field_path_length=4), + ), + ), + ) + + response = client.get("/users", params={"sort": "company.name,asc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" + + +def test_integration_rejects_page_size_above_maximum_limit( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Relies on FastAPI query validation to reject oversized page sizes.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig(max_page_size=5), + ) + + response = client.get("/users", params={"size": 6}) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "size"] diff --git a/tests/security/test_fastapi_sqlalchemy_policies.py b/tests/security/test_fastapi_sqlalchemy_policies.py new file mode 100644 index 0000000..865fc89 --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_policies.py @@ -0,0 +1,180 @@ +"""Security tests for allowlist and denylist enforcement at the API edge.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from pyrsql.adapters.fastapi import FastAPICriteriaConfig +from pyrsql.core.options import QueryOptions, SortOptions + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_integration_rejects_filter_field_outside_whitelist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects filter selectors not present in the configured allowlist.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions(field_whitelist=frozenset({"name"})), + ), + ) + + response = client.get("/users", params={"filter": "company.name==acme"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "field_not_whitelisted" + ) + + +def test_integration_rejects_filter_field_in_blacklist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects filter selectors explicitly blocked by configuration.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions(field_blacklist=frozenset({"name"})), + ), + ) + + response = client.get("/users", params={"filter": "name==demo"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "field_blacklisted" + ) + + +def test_integration_rejects_filter_function_outside_whitelist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects unapproved functions before any ORM translation happens.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions(procedure_whitelist=("lower",)), + ), + ) + + response = client.get("/users", params={"filter": "@upper[name]==DEMO"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "function_not_whitelisted" + ) + + +def test_integration_rejects_filter_function_in_blacklist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects explicitly denied functions even if they match the allowlist.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions( + procedure_whitelist=(".*",), + procedure_blacklist=("upper",), + ), + ), + ) + + response = client.get("/users", params={"filter": "@upper[name]==DEMO"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "function_blacklisted" + ) + + +def test_integration_rejects_sort_field_outside_whitelist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects sort selectors not present in the configured allowlist.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions(field_whitelist=frozenset({"name"})), + ), + ) + + response = client.get("/users", params={"sort": "company.name,asc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "sort_field_not_whitelisted" + ) + + +def test_integration_rejects_sort_field_in_blacklist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects sort selectors explicitly blocked by configuration.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions(field_blacklist=frozenset({"name"})), + ), + ) + + response = client.get("/users", params={"sort": "name,asc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "sort_field_blacklisted" + ) + + +def test_integration_rejects_sort_function_outside_whitelist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects unapproved sort functions before any SQL is generated.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions(procedure_whitelist=("lower",)), + ), + ) + + response = client.get("/users", params={"sort": "@upper[name],asc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "sort_function_not_whitelisted" + ) + + +def test_integration_rejects_sort_function_in_blacklist( + integration_app_factory: Callable[..., TestClient], +) -> None: + """Rejects explicitly denied sort functions even if globally allowed.""" + client = integration_app_factory( + criteria_config=FastAPICriteriaConfig( + sort_options=SortOptions( + procedure_whitelist=(".*",), + procedure_blacklist=("upper",), + ), + ), + ) + + response = client.get("/users", params={"sort": "@upper[name],asc"}) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "sort_function_blacklisted" + ) diff --git a/tests/security/test_fastapi_sqlalchemy_resource_security.py b/tests/security/test_fastapi_sqlalchemy_resource_security.py new file mode 100644 index 0000000..433bc11 --- /dev/null +++ b/tests/security/test_fastapi_sqlalchemy_resource_security.py @@ -0,0 +1,51 @@ +"""Security tests for declarative FastAPI SQLAlchemy resources.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.security, + pytest.mark.functional, + pytest.mark.fastapi, + pytest.mark.sqlalchemy, +] + + +def test_resource_rejects_filter_field_outside_declared_allowlist( + resource_app_factory: Callable[..., TestClient], +) -> None: + """Rejects filters on fields excluded by declarative resource config.""" + response = resource_app_factory(filterable_fields={"name"}).get( + "/users", + params={"filter": "company.name==acme"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "field_not_whitelisted" + ) + + +def test_resource_rejects_sort_field_outside_declared_allowlist( + resource_app_factory: Callable[..., TestClient], +) -> None: + """Rejects sort fields excluded by declarative resource config.""" + response = resource_app_factory(sortable_fields={"name"}).get( + "/users", + params={"sort": "company.name,asc"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["errors"][0]["code"] == ( + "sort_field_not_whitelisted" + ) diff --git a/tests/security/test_sqlalchemy_injection.py b/tests/security/test_sqlalchemy_injection.py new file mode 100644 index 0000000..d4c699b --- /dev/null +++ b/tests/security/test_sqlalchemy_injection.py @@ -0,0 +1,77 @@ +"""Security tests for SQLAlchemy-backed injection resistance.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select +from sqlalchemy.dialects import postgresql +from tests.integration.sqlalchemy.conftest import JsonEvent, User + +import pyrsql +from pyrsql.core.json.options import JSONOptions +from pyrsql.core.options import QueryOptions +from pyrsql.orms.sqlalchemy import SQLAlchemyORM, SQLAlchemyORMError + +pytestmark = [ + pytest.mark.security, + pytest.mark.integration, + pytest.mark.sqlalchemy, +] + + +def test_scalar_filter_payload_is_bound_as_parameter() -> None: + """Treats SQL injection text as a scalar value, not SQL structure.""" + orm = SQLAlchemyORM() + payload = "demo' OR 1=1 --" + query = pyrsql.parse( + "name=='demo\\' OR 1=1 --'", + options=QueryOptions(strict_equality=True), + ) + + statement = orm.compile_query(query).apply(select(User), User) + compiled = statement.compile() + + assert payload not in str(compiled) + assert compiled.params["name_1"] == payload + + +def test_like_filter_payload_is_bound_as_parameter() -> None: + """Treats LIKE payload text as a bound parameter, not inline SQL.""" + orm = SQLAlchemyORM() + payload = "dem' OR 1=1 --" + query = pyrsql.parse("name=like='dem\\' OR 1=1 --'") + + statement = orm.compile_query(query).apply(select(User), User) + compiled = statement.compile() + + assert payload not in str(compiled) + assert compiled.params["name_1"] == f"%{payload}%" + + +def test_jsonpath_string_payload_stays_out_of_sql_structure() -> None: + """Keeps JSON path injection text inside a JSONPATH bind parameter.""" + orm = SQLAlchemyORM() + payload = '" ) || true || (\\"' + query = pyrsql.parse("""payload.name=='\\" ) || true || (\\\\"'""") + + statement = orm.compile_query(query).apply(select(JsonEvent), JsonEvent) + compiled = statement.compile(dialect=postgresql.dialect()) + + assert payload not in str(compiled) + assert "CAST(%(param_1)s AS JSONPATH)" in str(compiled) + assert '\\" ) || true || (\\\\\\""' in compiled.params["param_1"] + + +def test_sort_payload_is_rejected_by_model_resolution() -> None: + """Rejects a malicious second sort clause as an unmapped attribute.""" + orm = SQLAlchemyORM() + sort = pyrsql.Sort.parse("name,asc;DROP TABLE users") + + with pytest.raises(SQLAlchemyORMError, match="DROP TABLE users"): + orm.compile_sort(sort).apply(select(User), User) + + +def test_json_options_reject_sql_function_name_injection() -> None: + """Rejects unsafe SQL function names in JSON configuration.""" + with pytest.raises(ValueError, match="valid SQL identifier"): + JSONOptions(path_exists_function="jsonb_path_exists;DROP_TABLE") diff --git a/tests/security/test_sqlalchemy_jsonpath_security.py b/tests/security/test_sqlalchemy_jsonpath_security.py new file mode 100644 index 0000000..f0235ff --- /dev/null +++ b/tests/security/test_sqlalchemy_jsonpath_security.py @@ -0,0 +1,70 @@ +"""Security tests for JSON path regex and wildcard handling.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select +from sqlalchemy.dialects import postgresql +from tests.integration.sqlalchemy.conftest import JsonEvent + +import pyrsql +from pyrsql.orms.sqlalchemy import SQLAlchemyORM + +pytestmark = [ + pytest.mark.security, + pytest.mark.integration, + pytest.mark.sqlalchemy, +] + + +def test_json_like_escapes_regex_metacharacters_but_keeps_wildcards() -> None: + """Treats only ``*`` as wildcard while escaping other regex syntax.""" + orm = SQLAlchemyORM() + query = pyrsql.parse(r"""payload.name=like='a.*(b)[c]{2}|^$'""") + + statement = orm.compile_query(query).apply(select(JsonEvent), JsonEvent) + compiled = statement.compile(dialect=postgresql.dialect()) + rendered_path = compiled.params["param_1"] + + assert "CAST(%(param_1)s AS JSONPATH)" in str(compiled) + assert r"a\\..*" in rendered_path + assert r"\\(b\\)\\[c\\]\\{2\\}\\|\\^\\$" in rendered_path + + +def test_json_ilike_keeps_ignore_case_flag_inside_bound_jsonpath() -> None: + """Keeps case-insensitive regex flags inside the JSONPATH bind.""" + orm = SQLAlchemyORM() + query = pyrsql.parse("""payload.name=ilike='Demo'""") + + statement = orm.compile_query(query).apply(select(JsonEvent), JsonEvent) + compiled = statement.compile(dialect=postgresql.dialect()) + + assert 'flag "i"' not in str(compiled) + assert 'flag "i"' in compiled.params["param_1"] + + +def test_json_notlike_payload_stays_escaped_inside_jsonpath_bind() -> None: + """Treats malicious NOT LIKE payload text as escaped regex content.""" + orm = SQLAlchemyORM() + payload = 'x" ) || true || (\\\\"' + query = pyrsql.parse(r"""payload.name=notlike='x\" ) || true || (\\\"'""") + + statement = orm.compile_query(query).apply(select(JsonEvent), JsonEvent) + compiled = statement.compile(dialect=postgresql.dialect()) + + assert payload not in str(compiled) + assert "@ like_regex" in compiled.params["param_1"] + assert "|| true ||" not in compiled.params["param_1"] + assert '\\"' in compiled.params["param_1"] + + +def test_json_equality_with_wildcard_uses_bound_jsonpath_regex() -> None: + """Treats wildcard equality on JSON strings as regex over a bind value.""" + orm = SQLAlchemyORM() + query = pyrsql.parse("""payload.name=='dem*o'""") + + statement = orm.compile_query(query).apply(select(JsonEvent), JsonEvent) + compiled = statement.compile(dialect=postgresql.dialect()) + + assert "CAST(%(param_1)s AS JSONPATH)" in str(compiled) + assert 'like_regex "dem.*o"' in compiled.params["param_1"] From 7f458de8b7abfe9ae708a91feb213cf71f04abc1 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sat, 16 May 2026 19:53:26 -0300 Subject: [PATCH 06/13] docs: update --- README.md | 14 ++- docs/explanation/architecture.md | 39 ++++++ docs/index.md | 22 ++++ docs/quickstart.md | 76 +++++++++++- docs/reference/options.md | 97 ++++++++++----- docs/testing.md | 200 ++++++++++++++++--------------- docs/usage/async.md | 116 ++++++++++++++++++ docs/usage/fastapi-sqlalchemy.md | 96 ++++++++++++--- docs/usage/fastapi.md | 8 ++ mkdocs.yml | 2 + 10 files changed, 524 insertions(+), 146 deletions(-) create mode 100644 docs/usage/async.md diff --git a/README.md b/README.md index 0e9904a..73a222b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ one interface (`ORM`), not rewriting the parser or query language. - **Custom operators** - define your own RSQL operators with per-ORM lowering - **Field policies** - whitelist, blacklist, aliases at global and per-model level - **Type-safe** - strict mypy, Google-style docstrings, immutable value objects +- **Performance-oriented internals** - immutable `msgspec` models across the + core pipeline and configuration objects +- **Security-oriented request handling** - parser/sort limits, structural + allowlists/blocklists, sanitized FastAPI error payloads ## Quickstart @@ -161,6 +165,13 @@ def list_users(stmt = Depends(integration.select_dependency(User))): - `select_dependency`, `count_select_dependency`, `paginated_select_dependency` - Declarative `resource()` with auto-generated OpenAPI examples - `applier_dependency` for custom base statements +- Compatible with both sync `Session` and async `AsyncSession` execution +- Backend integration errors are translated into structured `HTTP 422` payloads + +### Concurrency and Validation + +- Shared integration and ORM metadata caches are protected for free-threaded execution +- Dedicated async, free-threaded, and security test suites validate these flows ## Documentation @@ -169,12 +180,13 @@ Full documentation at **[wskr00.github.io/pyrsql](https://wskr00.github.io/pyrsq | Section | Description | |---------|-------------| | [Quickstart](https://wskr00.github.io/pyrsql/quickstart/) | One-minute primer | -| [Usage](https://wskr00.github.io/pyrsql/usage/query/) | Filter, sort, page, JSON, FastAPI, custom predicates | +| [Usage](https://wskr00.github.io/pyrsql/usage/query/) | Filter, sort, page, JSON, FastAPI, async flows, custom predicates | | [API Reference](https://wskr00.github.io/pyrsql/reference/api/) | Auto-generated from docstrings | | [Operators](https://wskr00.github.io/pyrsql/reference/operators/) | Complete operator table | | [Options](https://wskr00.github.io/pyrsql/reference/options/) | QueryOptions, SortOptions, JSONOptions | | [Architecture](https://wskr00.github.io/pyrsql/explanation/architecture/) | Pipeline, modules, design | | [Extensibility](https://wskr00.github.io/pyrsql/explanation/extensibility/) | Adding backends and adapters | +| [Testing](https://wskr00.github.io/pyrsql/testing/) | Test layers, async, security, free-threaded validation | | [Contributing](https://wskr00.github.io/pyrsql/contributing/) | Setup, workflow, standards | ## Development Principles diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index d5f8c1a..6437692 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -230,6 +230,7 @@ The project follows: - explicit invariants - backend-independent core semantics - performance-oriented data modeling +- explicit concurrency boundaries ### Practical interpretation @@ -247,6 +248,11 @@ The project uses `msgspec` aggressively where it helps: - IR nodes - parser/source structures - adapter and integration payloads +- core query/sort/options/value-policy models + +The current direction is to prefer immutable `msgspec.Struct` models for +hot-path internal data wherever that improves compactness, validation, and +normalization consistency. Other performance principles: @@ -255,6 +261,39 @@ Other performance principles: - keep hot-path allocations small - prefer explicit invariants over defensive branching spread everywhere +## Concurrency and runtime model + +`pyrsql` is intentionally mostly pure compilation logic: + +- parse input +- bind semantics +- lower to ORM statements + +It does not own database sessions or perform network/database I/O itself. + +That has two consequences: + +- async compatibility is achieved by executing the generated statements with + `AsyncSession`, not by duplicating the whole core as “async code” +- free-threaded support depends mainly on protecting shared caches and keeping + configuration/value models immutable + +Shared caches in FastAPI integrations and SQLAlchemy metadata helpers are +guarded explicitly for free-threaded execution. + +## Security boundary + +`pyrsql` can provide: + +- structural validation of selectors and functions +- parser/sort complexity limits +- safe value binding through backend lowering +- sanitized FastAPI error payloads + +It does not replace application-level authorization. Exposing a field safely at +the SQL layer is not the same thing as authorizing clients to filter or sort by +that field. That policy remains with the application. + For JSON specifically: - whole-document predicates avoid unnecessary `jsonpath` construction diff --git a/docs/index.md b/docs/index.md index ed0cab8..12c20e1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,3 +34,25 @@ stmt = pyrsql.parse("name==demo;age=gt=18").apply(select(User), User, orm=orm) - **Field policies** - whitelist, blacklist, aliases (global + per-model) - **JSON / JSONB** - PostgreSQL `jsonpath` with structured values and datetime support - **Type-safe** - strict mypy, Google-style docstrings, immutable value objects +- **Async-compatible execution** - generated SQLAlchemy statements work with + both `Session` and `AsyncSession` +- **Security-oriented request handling** - parser/sort limits, structural + allowlists/blocklists, and sanitized FastAPI error payloads +- **Free-threaded-safe shared caches** - integration and ORM helper caches are + guarded for concurrent access + +## Integration options + +- **FastAPI adapter** - parse `filter`, `sort`, `page`, and `size` into + `RequestCriteria` +- **FastAPI + SQLAlchemy integration** - route-ready dependencies, + declarative `resource()` helpers, count/paginated bundles, and normalized + backend `HTTP 422` translation + +## Next steps + +- [Quickstart](quickstart.md) +- [FastAPI Adapter](usage/fastapi.md) +- [FastAPI + SQLAlchemy](usage/fastapi-sqlalchemy.md) +- [Async Flows](usage/async.md) +- [Architecture](explanation/architecture.md) diff --git a/docs/quickstart.md b/docs/quickstart.md index 8467726..7c79c16 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -45,27 +45,95 @@ PageRequest.of(2, 25) # third page, 25 items ## SQLAlchemy ```python +from pyrsql import PageRequest, Query, Sort from pyrsql.orms.sqlalchemy import SQLAlchemyORM from sqlalchemy import select orm = SQLAlchemyORM() -# Apply to a SQLAlchemy Select -stmt = Query.parse("name==demo").apply(select(User), User, orm=orm) +stmt = select(User) +stmt = Query.parse("name==demo;company.name==acme*").apply( + stmt, + User, + orm=orm, +) +stmt = Sort.parse("name,asc;company.name,desc").apply(stmt, User, orm=orm) +stmt = PageRequest.of(0, 25).apply(stmt, User, orm=orm) ``` ## FastAPI ```python +from typing import Annotated + from fastapi import Depends, FastAPI from pyrsql.adapters.fastapi import RequestCriteria, criteria_dependency app = FastAPI() @app.get("/items") -def list_items(criteria = Depends(criteria_dependency())): - ... +def list_items( + criteria: Annotated[RequestCriteria, Depends(criteria_dependency())], +): + return {"is_empty": criteria.is_empty} ``` Query params `filter`, `sort`, `page`, `size` are extracted automatically. Parse and semantic errors become `HTTP 422` with structured diagnostics. + +## FastAPI + SQLAlchemy + +```python +from typing import Annotated, Any + +from fastapi import Depends, FastAPI + +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration + +app = FastAPI() +integration = FastAPISQLAlchemyIntegration() + +@app.get("/users") +def list_users( + stmt: Annotated[Any, Depends(integration.select_dependency(User))], +): + return {"sql": str(stmt)} +``` + +Also available: + +- `count_select_dependency(...)` +- `paginated_select_dependency(...)` +- declarative `resource(...)` + +## Async execution + +```python +from typing import Annotated, Any + +from fastapi import Depends, FastAPI +from sqlalchemy.ext.asyncio import AsyncSession + +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration + +app = FastAPI() +integration = FastAPISQLAlchemyIntegration() + +@app.get("/users") +async def list_users( + stmt: Annotated[Any, Depends(integration.select_dependency(User))], + session: Annotated[AsyncSession, Depends(get_async_session)], +): + result = await session.scalars(stmt) + return result.all() +``` + +`pyrsql` builds statements only; your application decides whether to execute +them through sync or async SQLAlchemy sessions. + +## Notes + +- Shared integration and ORM metadata caches are validated for free-threaded + execution. +- Dedicated async, free-threaded, and security suites are documented in + [Testing](testing.md). diff --git a/docs/reference/options.md b/docs/reference/options.md index ec00b8e..0762992 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -6,40 +6,53 @@ |-------|------|---------| | `strict_equality` | `bool` | `False` | | `distinct` | `bool` | `False` | -| `like_escape_character` | `str\|None` | `None` | -| `field_mapping` | `Mapping[str,str]` | `{}` | -| `model_field_mapping` | `Mapping[type,Mapping[str,str]]` | `{}` | -| `join_hints` | `Mapping[str,JoinHint]` | `{}` | +| `like_escape_character` | `str \| None` | `None` | +| `field_mapping` | `Mapping[str, str]` | `{}` | +| `model_field_mapping` | `Mapping[type, Mapping[str, str]]` | `{}` | +| `join_hints` | `Mapping[str, JoinHint]` | `{}` | | `field_whitelist` | `frozenset[str]` | `frozenset()` | | `field_blacklist` | `frozenset[str]` | `frozenset()` | -| `model_field_whitelist` | `Mapping[type,frozenset[str]]` | `{}` | -| `model_field_blacklist` | `Mapping[type,frozenset[str]]` | `{}` | -| `procedure_whitelist` | `tuple[str,...]` | `()` | -| `procedure_blacklist` | `tuple[str,...]` | `()` | +| `model_field_whitelist` | `Mapping[type, frozenset[str]]` | `{}` | +| `model_field_blacklist` | `Mapping[type, frozenset[str]]` | `{}` | +| `procedure_whitelist` | `tuple[str, ...]` | `()` | +| `procedure_blacklist` | `tuple[str, ...]` | `()` | | `parse_limits` | `ParseLimits` | `DEFAULT_PARSE_LIMITS` | | `operator_registry` | `OperatorRegistry` | `DEFAULT_OPERATOR_REGISTRY` | -| `custom_predicates` | `Mapping[str,CustomPredicateDefinition]` | `{}` | -| `value_converter_registry` | `ValueConverterRegistry` | `DEFAULT_*` | -| `field_value_converters` | `Mapping[str,ValueConverter]` | `{}` | -| `model_field_value_converters` | `Mapping[type,Mapping[str,ValueConverter]]` | `{}` | +| `custom_predicates` | `Mapping[str, CustomPredicateDefinition]` | `{}` | +| `value_converter_registry` | `ValueConverterRegistry` | `DEFAULT_VALUE_CONVERTER_REGISTRY` | +| `field_value_converters` | `Mapping[str, ValueConverter]` | `{}` | +| `model_field_value_converters` | `Mapping[type, Mapping[str, ValueConverter]]` | `{}` | | `json_options` | `JSONOptions` | `DEFAULT_JSON_OPTIONS` | +Notes: + +- configuration containers are normalized into immutable views +- procedure allow/block rules are compiled as regex policies and evaluated with + `fullmatch`, not partial match +- built-in scalar conversion uses `msgspec`-backed conversion paths where + possible + ## SortOptions | Field | Type | Default | |-------|------|---------| -| `field_mapping` | `Mapping[str,str]` | `{}` | -| `model_field_mapping` | `Mapping[type,Mapping[str,str]]` | `{}` | -| `join_hints` | `Mapping[str,JoinHint]` | `{}` | +| `field_mapping` | `Mapping[str, str]` | `{}` | +| `model_field_mapping` | `Mapping[type, Mapping[str, str]]` | `{}` | +| `join_hints` | `Mapping[str, JoinHint]` | `{}` | | `field_whitelist` | `frozenset[str]` | `frozenset()` | | `field_blacklist` | `frozenset[str]` | `frozenset()` | -| `model_field_whitelist` | `Mapping[type,frozenset[str]]` | `{}` | -| `model_field_blacklist` | `Mapping[type,frozenset[str]]` | `{}` | -| `procedure_whitelist` | `tuple[str,...]` | `()` | -| `procedure_blacklist` | `tuple[str,...]` | `()` | +| `model_field_whitelist` | `Mapping[type, frozenset[str]]` | `{}` | +| `model_field_blacklist` | `Mapping[type, frozenset[str]]` | `{}` | +| `procedure_whitelist` | `tuple[str, ...]` | `()` | +| `procedure_blacklist` | `tuple[str, ...]` | `()` | | `sort_limits` | `SortLimits` | `DEFAULT_SORT_LIMITS` | | `json_options` | `JSONOptions` | `DEFAULT_JSON_OPTIONS` | +Notes: + +- sort field and function restrictions follow the same policy model as queries +- sort configuration is immutable and shareable across threads/tasks + ## JSONOptions | Field | Type | Default | @@ -47,20 +60,48 @@ | `use_datetime` | `bool` | `False` | | `path_exists_function` | `str` | `"jsonb_path_exists"` | | `path_exists_tz_function` | `str` | `"jsonb_path_exists_tz"` | -| `sort_field_types` | `Mapping[str,JSONSortScalarType]` | `{}` | +| `sort_field_types` | `Mapping[str, JSONSortScalarType]` | `{}` | + +Notes: + +- PostgreSQL function names are validated as SQL identifiers +- invalid names such as `jsonb_path_exists;DROP_TABLE` are rejected at config + construction time +- `sort_field_types` keys are normalized and stored immutably ## JSONSortScalarType -`TEXT`, `INTEGER`, `FLOAT`, `NUMERIC`, `BOOLEAN`, `DATE`, `TIME`, -`DATETIME`, `DATETIME_TZ`. +Supported values: + +- `TEXT` +- `INTEGER` +- `FLOAT` +- `NUMERIC` +- `BOOLEAN` +- `DATE` +- `TIME` +- `DATETIME` +- `DATETIME_TZ` ## ParseLimits defaults | Field | Default | |-------|---------| -| `max_query_length` | 4096 | -| `max_selector_length` | 256 | -| `max_argument_length` | 1024 | -| `max_arguments_per_list` | 100 | -| `max_expression_depth` | 16 | -| `max_node_count` | 1024 | +| `max_query_length` | `4096` | +| `max_selector_length` | `256` | +| `max_argument_length` | `1024` | +| `max_arguments_per_list` | `100` | +| `max_expression_depth` | `16` | +| `max_node_count` | `1024` | + +These limits exist for both robustness and security hardening. + +## SortLimits defaults + +| Field | Default | +|-------|---------| +| `max_sort_length` | `4096` | +| `max_fields` | `32` | +| `max_field_path_length` | `256` | + +These limits bound sort-parser complexity and request-surface size. diff --git a/docs/testing.md b/docs/testing.md index 602e2ae..c4638d1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,9 +8,9 @@ technology alone. The structure should make it obvious: - what is being tested - at which isolation level it is being tested - which tests are safe to run by default -- which tests are intended for regression detection or benchmarking +- which tests are intended for regression detection, hardening, or benchmarking -## Test Layers +## Test layers ### `unit` @@ -23,14 +23,6 @@ Rules: - avoid real database or end-to-end ORM flows - prefer direct construction of objects over full public API orchestration -Examples: - -- `src/pyrsql/parsing/lexer.py` -- `tests/unit/parsing/test_lexer.py` - -- `src/pyrsql/core/json/path.py` -- `tests/unit/core/json/test_path.py` - ### `integration` Integration tests validate interaction between components. @@ -39,14 +31,7 @@ Rules: - organize by pipeline or subsystem, not by source file - allow multiple project layers to participate in one test -- ORM-specific integration tests may use real SQLAlchemy models or - in-memory database setup - -Examples: - -- parser -> semantic analyzer -> ORM compiler -- query object -> SQLAlchemy ORM -> `Select` -- JSON path resolution -> SQLAlchemy translation +- ORM-specific integration tests may use real SQLAlchemy models ### `functional` @@ -58,115 +43,142 @@ Rules: - use public API where possible - focus on behavior and regressions, not internals -Examples: - -- filtering behavior -- sorting behavior -- pagination behavior -- JSON behavior -- error message behavior - ### `performance` Performance tests validate that hot paths do not regress. Rules: -- never rely on them as correctness tests +- do not treat them as correctness tests - mark them explicitly - keep them out of the default fast feedback loop -- measure representative workloads rather than micro-optimizing blindly -Examples: +### `security` -- lexer throughput -- parser throughput -- selector parsing throughput -- semantic normalization throughput -- SQLAlchemy translation throughput +Security tests validate that hostile or malformed input is handled safely. -## Mapping Rules +Rules: -Unit tests mirror the production tree. Integration tests group by pipeline. -Functional tests group by capability. Performance tests group by hotspot. +- use offensive payloads deliberately +- assert stable failure modes, not only success paths +- validate both SQL/statement shape and HTTP error shape where relevant +- keep one attack contract per test -Examples: +Current `security` coverage includes: -- `src/pyrsql/core/options.py` → `tests/unit/core/test_options.py` -- `src/pyrsql/parsing/parser.py` → `tests/unit/parsing/test_parser.py` -- `src/pyrsql/orms/sqlalchemy/resolver.py` → `tests/unit/orms/sqlalchemy/test_resolver.py` +- scalar SQL injection payloads treated as bound values +- structural field/function abuse rejected by allowlists and blocklists +- parser and sort complexity limits +- JSONPATH and `like_regex` escaping +- duplicate query parameter handling at the FastAPI boundary +- non-verbose error payloads -## Fixtures and Test Helpers +## Fixtures and helpers ### `conftest.py` hierarchy -pyrsql uses per-folder `conftest.py` files for scoped fixtures: +`pyrsql` uses per-folder `conftest.py` files for scoped fixtures: | Level | Purpose | |-------|---------| -| `tests/conftest.py` | Shared between all tests packages | -| `tests/unit/conftest.py` | Auto-marks `@pytest.mark.unit` on all unit tests | -| `tests/unit/core/conftest.py` | `FakeORM`, `FakeCompiledResult`, `fake_orm_factory` | -| `tests/unit/orms/sqlalchemy/conftest.py` | SQLAlchemy test models, `model_inspector`, `path_resolver`, `json_path_builder`, `postgresql_dialect` | -| `tests/unit/adapters/fastapi_adapter/conftest.py` | `query_stub`, `sort_stub`, `page_request`, `openapi_examples` | -| `tests/unit/integrations/fastapi_sqlalchemy/conftest.py` | SQLAlchemy models, `sqlalchemy_orm`, `integration`, fixture-based criteria | -| `tests/integration/sqlalchemy/conftest.py` | Shared SQLAlchemy models, `orm`, `pg_dialect`, `render_sql` helper | -| `tests/functional/fastapi_sqlalchemy/conftest.py` | Shared `Base`/`User` models | -| `tests/performance/conftest.py` | Shared test-data constants, SQLAlchemy models, `sqlalchemy_orm`, `pg_dialect` | - -### Mocking patterns - -Unit tests that verify **orchestration** (object A delegates to object B) use -`unittest.mock` and `pytest.monkeypatch`: - -```python -from unittest.mock import Mock, sentinel - -# Replacing a class method with a controlled return value -parse_mock = Mock(return_value=sentinel.EXPRESSION) -monkeypatch.setattr(Query, "parse_expression", staticmethod(parse_mock)) - -# Assert the mock was called correctly -parse_mock.assert_called_once_with("name==demo", options=options) +| `tests/conftest.py` | Shared between all test packages | +| `tests/unit/.../conftest.py` | Unit-scoped fakes and local helpers | +| `tests/integration/sqlalchemy/conftest.py` | Shared SQLAlchemy models, `orm`, PostgreSQL dialect helpers | +| `tests/functional/fastapi_sqlalchemy/conftest.py` | Shared SQLAlchemy models for FastAPI integration tests | +| `tests/security/conftest.py` | Shared FastAPI app factories and response-sanitization helpers | +| `tests/performance/conftest.py` | Shared benchmark fixtures and models | + +Create a local `conftest.py` when it removes real repetition. Avoid using it +to hide the main action of the test. + +## Pytest markers + +Registered markers: + +- `unit` +- `integration` +- `functional` +- `performance` +- `security` +- `sqlalchemy` +- `fastapi` + +Examples: + +```bash +uv run pytest -m security +uv run pytest -m "functional and fastapi" +uv run pytest -m "integration and sqlalchemy" ``` -- **`sentinel`** replaces `object()` for named stub values - stack traces - show `sentinel.FIELDS` instead of `` -- **`Mock(return_value=...)`** replaces `staticmethod(lambda ...)` - - supports `assert_called_once_with` for argument verification -- **`Mock(side_effect=...)`** replaces manual `calls: list` tracking - - built-in call recording with `assert_has_calls` +## Async validation + +Async support is validated at three levels: -Tests that are **pure value tests** (create object, pass input, check output) -do not use mocks - they use real instances directly. +- adapter behavior on `async def` routes +- SQLAlchemy pipeline execution with `AsyncSession` +- FastAPI + SQLAlchemy end-to-end async integration -## Pytest Markers +Typical commands: -Registered markers (in `pyproject.toml`): +```bash +uv run pytest tests/functional/fastapi_adapter/test_async_dependency.py -q +uv run pytest tests/integration/sqlalchemy/test_async_pipeline.py -q +uv run pytest tests/functional/fastapi_sqlalchemy/test_async_integration.py -q +``` -- `unit` - isolated module/class tests -- `integration` - cross-component interaction -- `functional` - user-visible behavior via public API -- `performance` - hotspot benchmarks -- `sqlalchemy` - SQLAlchemy-dependent tests -- `fastapi` - FastAPI-dependent tests +## Free-threaded validation -## Free-Threaded Validation +Free-threaded correctness depends primarily on avoiding unsynchronized access to +shared mutable state. -For free-threaded Python support, correctness depends primarily on avoiding -unsynchronized access to shared mutable state. In `pyrsql`, this means: +In `pyrsql`, the main targets are: -- immutable configuration/value objects should remain shareable across threads -- caches shared by integrations and ORM helpers should use explicit locking -- tests should exercise concurrent access to those caches directly +- integration dependency caches +- SQLAlchemy model-introspection caches +- resolver caches Recommended validation flow: ```bash -uv run pytest tests/unit/integrations/fastapi_sqlalchemy/test_integration.py \ - tests/unit/orms/sqlalchemy/test_resolver.py +uv run pytest \ + tests/unit/integrations/fastapi_sqlalchemy/test_integration.py \ + tests/unit/orms/sqlalchemy/test_resolver.py -q +``` + +When a free-threaded CPython build is available locally, rerun with the GIL +disabled. Example: + +```bash +PYTHON_GIL=0 python -m pytest \ + tests/unit/integrations/fastapi_sqlalchemy/test_integration.py \ + tests/unit/orms/sqlalchemy/test_resolver.py -q ``` -When a free-threaded CPython build is available locally, run the same tests -with the GIL disabled as part of regression checks, for example with -`PYTHON_GIL=0` or `-X gil=0` depending on how Python was installed. +## Security validation + +Recommended commands: + +```bash +uv run pytest tests/security -q +uv run pytest -m security +``` + +The security suite is intentionally broader than “SQL injection only”. It also +covers: + +- resource-consumption limits +- invalid structural selectors +- JSON path escaping +- duplicate parameter handling +- sanitized integration errors + +## Design notes + +Testing follows a few explicit conventions: + +- one behavior per test +- Arrange / Act / Assert should stay obvious +- use fixtures for shared setup, not for hiding assertions +- prefer public APIs in functional/security tests +- keep backend assertions narrow and stable diff --git a/docs/usage/async.md b/docs/usage/async.md new file mode 100644 index 0000000..c31bafb --- /dev/null +++ b/docs/usage/async.md @@ -0,0 +1,116 @@ +# Async Flows + +`pyrsql` itself does not perform database I/O. It builds SQLAlchemy statements +and criteria objects. Because of that, the same compiled `Select` objects work +for both: + +- synchronous `Session` +- asynchronous `AsyncSession` + +The async work happens in your application when you execute the statement. + +## SQLAlchemy async session + +```python +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +engine = create_async_engine( + "sqlite+aiosqlite:///./app.db", +) +SessionFactory = async_sessionmaker( + engine, + expire_on_commit=False, +) + +async def get_async_session(): + async with SessionFactory() as session: + yield session +``` + +`expire_on_commit=False` is the recommended baseline for async SQLAlchemy +usage. + +## FastAPI async route + +```python +from typing import Annotated, Any + +from fastapi import Depends, FastAPI +from sqlalchemy.ext.asyncio import AsyncSession + +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration + +app = FastAPI() +integration = FastAPISQLAlchemyIntegration() + +@app.get("/users") +async def list_users( + stmt: Annotated[Any, Depends(integration.select_dependency(User))], + session: Annotated[AsyncSession, Depends(get_async_session)], +): + result = await session.scalars(stmt) + return result.all() +``` + +The dependency is still synchronous from `pyrsql`'s perspective because it only +parses request data and builds one SQLAlchemy `Select`. + +## Count and pagination + +```python +@app.get("/users/count") +async def count_users( + stmt: Annotated[Any, Depends(integration.count_select_dependency(User))], + session: Annotated[AsyncSession, Depends(get_async_session)], +): + return {"count": await session.scalar(stmt)} + +@app.get("/users/paginated") +async def paginated_users( + bundle: Annotated[ + Any, + Depends(integration.paginated_select_dependency(User)), + ], + session: Annotated[AsyncSession, Depends(get_async_session)], +): + items = (await session.scalars(bundle.statement)).all() + total = await session.scalar(bundle.count_statement) + return {"items": items, "total": total} +``` + +## Direct async execution without FastAPI + +```python +from sqlalchemy import select + +import pyrsql + +query = pyrsql.parse("name==demo") +stmt = query.apply(select(User), User, orm=sqlalchemy_orm) + +async with SessionFactory() as session: + result = await session.scalars(stmt) + users = result.all() +``` + +## Constraints and guidance + +- Do not share one `AsyncSession` across concurrent tasks. +- `pyrsql` can safely reuse immutable options/configuration objects across async + requests. +- The integration layer only builds statements; it does not manage session + lifetime. +- Parse, semantic, and backend integration failures still become structured + `HTTP 422` responses in FastAPI. + +## Test coverage + +The test suite includes async coverage at three levels: + +- FastAPI adapter behavior on `async def` routes +- SQLAlchemy pipeline execution with `AsyncSession` +- FastAPI + SQLAlchemy end-to-end async integration diff --git a/docs/usage/fastapi-sqlalchemy.md b/docs/usage/fastapi-sqlalchemy.md index 5e90189..2a06fb0 100644 --- a/docs/usage/fastapi-sqlalchemy.md +++ b/docs/usage/fastapi-sqlalchemy.md @@ -1,4 +1,4 @@ -# FastAPI + SQLAlchemy Integration +# FastAPI + SQLAlchemy ## Setup @@ -8,14 +8,28 @@ from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration integration = FastAPISQLAlchemyIntegration() ``` -## Dependency factories +The integration composes: + +- the FastAPI adapter, which parses `filter`, `sort`, `page`, and `size` +- the SQLAlchemy backend, which lowers compiled criteria into `Select` + statements + +It does not execute database I/O. It only builds SQLAlchemy statements. + +## Route-ready dependencies ```python +from typing import Annotated, Any + +from fastapi import Depends, FastAPI + +app = FastAPI() + @app.get("/users") def list_users( stmt: Annotated[Any, Depends(integration.select_dependency(User))], ): - return {"sql": str(stmt.compile(compile_kwargs={"literal_binds": True}))} + return {"sql": str(stmt)} @app.get("/users/count") def count_users( @@ -25,13 +39,22 @@ def count_users( @app.get("/users/paginated") def paginated_users( - bundle: Annotated[Any, Depends(integration.paginated_select_dependency(User))], + bundle: Annotated[ + Any, + Depends(integration.paginated_select_dependency(User)), + ], ): - # bundle.statement -> the filtered + sorted + paged SELECT - # bundle.count_statement -> the filtered count SELECT + # bundle.statement -> filtered + sorted + paged SELECT + # bundle.count_statement -> filtered count SELECT ... ``` +`paginated_select_dependency(...)` returns a +`SQLAlchemyPaginatedSelect` bundle with: + +- `statement` +- `count_statement` + ## Declarative resources ```python @@ -41,22 +64,37 @@ users = integration.resource( sortable_fields={"name"}, default_sort="name,desc", max_page_size=50, - filter_examples={"by_name": {"summary": "By name", "value": "name==demo"}}, + filter_examples={ + "by_name": {"summary": "By name", "value": "name==demo"}, + }, ) @app.get("/users") -def list_users(stmt: Annotated[Any, Depends(users.select_dependency())]): +def list_users( + stmt: Annotated[Any, Depends(users.select_dependency())], +): ... ``` -Resources auto-generate OpenAPI examples for filterable and sortable fields. +Resources are useful when one model/route pair has a stable public contract: + +- allowed filter fields +- allowed sort fields +- default sort +- OpenAPI examples +- page-size limits + +Also available: -Also available: `count_select_dependency()`, `paginated_select_dependency()`, -`applier_dependency()`. +- `users.count_select_dependency()` +- `users.paginated_select_dependency()` +- `users.applier_dependency()` ## Custom base statement ```python +from sqlalchemy import select + users = integration.resource( User, statement_factory=lambda: select(User).where(User.status == "active"), @@ -64,6 +102,9 @@ users = integration.resource( ) ``` +Use `statement_factory` when the public route should always start from one +restricted base query. + ## Applying criteria directly ```python @@ -73,13 +114,30 @@ stmt = integration.count_select(User, request_criteria) bundle = integration.paginated_select(User, request_criteria) ``` -## Custom ORM configuration +These helpers are useful outside route dependencies, for example in service +layers or tests. -```python -from pyrsql.adapters.fastapi import FastAPICriteriaConfig +## Error behavior -integration = FastAPISQLAlchemyIntegration( - orm=SQLAlchemyORM(...), - criteria_config=FastAPICriteriaConfig(default_page_size=20), -) -``` +Adapter-layer failures still return the structured FastAPI `422` payloads from +the request adapter. + +Backend-layer failures are also normalized to `422` in the integration layer. +Examples: + +- filtering on an unmapped field +- sorting on an unmapped field +- backend-specific lowering errors + +This avoids leaking raw SQLAlchemy exceptions as `500` responses. + +## Free-threaded behavior + +The integration caches dependency callables and base `select(model)` statements +per model. Those shared caches are protected explicitly for free-threaded +execution. + +## Async note + +This page shows the synchronous route shape. The integration is also compatible +with `async def` routes and `AsyncSession`; see [Async Flows](async.md). diff --git a/docs/usage/fastapi.md b/docs/usage/fastapi.md index 8ea2c81..e886b8e 100644 --- a/docs/usage/fastapi.md +++ b/docs/usage/fastapi.md @@ -18,6 +18,10 @@ def list_items(criteria: Annotated[RequestCriteria, Depends(dependency)]): The adapter extracts `filter`, `sort`, `page`, and `size` from query parameters and builds a `RequestCriteria`. +The dependency itself does not need to be `async`. It is compatible with both +sync and async FastAPI routes because it only parses request data and builds +criteria objects. + ## Configuration ```python @@ -84,3 +88,7 @@ with a structured payload: Error types: `query_parse_error`, `query_semantic_error`, `sort_parse_error`, `sort_semantic_error`, page validation errors. + +When used together with the SQLAlchemy integration layer, backend ORM failures +such as unmapped fields are also translated into structured `HTTP 422` +responses instead of bubbling up as raw `500` errors. diff --git a/mkdocs.yml b/mkdocs.yml index df7577b..073b7c5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Join Hints: usage/join-hints.md - FastAPI Adapter: usage/fastapi.md - FastAPI + SQLAlchemy: usage/fastapi-sqlalchemy.md + - Async Flows: usage/async.md - Reference: - API: reference/api.md - Operators: reference/operators.md @@ -75,4 +76,5 @@ nav: - Explanation: - Architecture: explanation/architecture.md - Extensibility: explanation/extensibility.md + - Testing: testing.md - Contributing: contributing.md From 86ff48652cd9626143f01bf47a9fa9a04a97ac85 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sun, 24 May 2026 00:25:03 -0300 Subject: [PATCH 07/13] refactor: removes unnecessary internal error checks --- src/pyrsql/_policy.py | 75 ++ src/pyrsql/_validation.py | 16 + src/pyrsql/core/compiler.py | 112 +-- src/pyrsql/core/conversion.py | 221 +++-- src/pyrsql/core/custom.py | 31 +- src/pyrsql/core/field_policy.py | 70 +- src/pyrsql/core/json/__init__.py | 7 +- src/pyrsql/core/json/options.py | 83 +- src/pyrsql/core/json/path.py | 38 +- src/pyrsql/core/json/query.py | 19 +- src/pyrsql/core/json/values.py | 27 +- src/pyrsql/core/options.py | 174 ++-- src/pyrsql/core/page.py | 43 +- src/pyrsql/core/procedure_policy.py | 96 ++- src/pyrsql/core/query.py | 55 +- src/pyrsql/core/sort.py | 57 +- .../fastapi/sqlalchemy/integration.py | 16 +- .../fastapi/sqlalchemy/resource.py | 12 +- src/pyrsql/ir/__init__.py | 4 - src/pyrsql/ir/page.py | 12 - src/pyrsql/ir/query.py | 55 +- src/pyrsql/ir/sort.py | 24 +- src/pyrsql/orms/sqlalchemy/introspection.py | 67 +- src/pyrsql/orms/sqlalchemy/resolver.py | 9 +- src/pyrsql/parsing/__init__.py | 5 +- src/pyrsql/parsing/ast.py | 36 +- src/pyrsql/parsing/errors.py | 5 +- src/pyrsql/parsing/lexer.py | 119 ++- src/pyrsql/parsing/limits.py | 46 +- src/pyrsql/parsing/operators.py | 100 ++- src/pyrsql/parsing/parser.py | 72 +- src/pyrsql/parsing/source.py | 31 - src/pyrsql/selector/__init__.py | 7 +- src/pyrsql/selector/ast.py | 40 - src/pyrsql/selector/parser.py | 144 +++- src/pyrsql/semantic/binder.py | 178 ++-- src/pyrsql/semantic/errors.py | 5 +- src/pyrsql/sorting/ast.py | 18 +- src/pyrsql/sorting/binder.py | 159 ++-- src/pyrsql/sorting/diagnostics.py | 2 + src/pyrsql/sorting/errors.py | 4 +- src/pyrsql/sorting/limits.py | 28 +- src/pyrsql/sorting/parser.py | 78 +- tests/__init__.py | 1 + .../sqlalchemy/test_async_pipeline.py | 2 +- tests/performance/test_path_resolver_bench.py | 1 - tests/security/conftest.py | 2 +- ...t_fastapi_sqlalchemy_error_sanitization.py | 2 +- tests/security/test_sqlalchemy_injection.py | 2 +- .../test_sqlalchemy_jsonpath_security.py | 2 +- tests/unit/core/json/test_options.py | 14 + tests/unit/core/json/test_path.py | 25 +- tests/unit/core/json/test_query.py | 35 +- tests/unit/core/json/test_values.py | 41 + tests/unit/core/test_conversion.py | 37 + tests/unit/core/test_custom.py | 56 ++ tests/unit/core/test_options.py | 49 +- tests/unit/ir/test_page.py | 19 - tests/unit/ir/test_query.py | 16 +- tests/unit/ir/test_sort.py | 13 +- tests/unit/orms/sqlalchemy/test_resolver.py | 2 - tests/unit/parsing/test_ast.py | 66 ++ tests/unit/parsing/test_lexer.py | 32 + tests/unit/parsing/test_operators.py | 105 +++ tests/unit/parsing/test_parser.py | 93 ++- tests/unit/parsing/test_source.py | 27 + tests/unit/selector/test_ast.py | 31 + tests/unit/selector/test_parser.py | 100 +++ tests/unit/semantic/test_binder.py | 26 + tests/unit/sorting/test_ast.py | 26 + tests/unit/sorting/test_binder.py | 24 + tests/unit/sorting/test_parser.py | 74 ++ uv.lock | 788 +++++++++--------- 73 files changed, 2552 insertions(+), 1559 deletions(-) create mode 100644 src/pyrsql/_policy.py create mode 100644 src/pyrsql/_validation.py create mode 100644 tests/__init__.py create mode 100644 tests/unit/core/test_custom.py create mode 100644 tests/unit/parsing/test_ast.py create mode 100644 tests/unit/parsing/test_operators.py create mode 100644 tests/unit/parsing/test_source.py create mode 100644 tests/unit/selector/test_ast.py create mode 100644 tests/unit/sorting/test_ast.py diff --git a/src/pyrsql/_policy.py b/src/pyrsql/_policy.py new file mode 100644 index 0000000..b688129 --- /dev/null +++ b/src/pyrsql/_policy.py @@ -0,0 +1,75 @@ +"""Shared policy contracts and enforcement helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class ProcedurePolicyProtocol(Protocol): + """Structural contract for procedure access policies.""" + + def is_whitelisted(self, function_name: str) -> bool: + """Returns whether the function is allowed by the whitelist.""" + + def is_blacklisted(self, function_name: str) -> bool: + """Returns whether the function is blocked by the blacklist.""" + + +class MappedFieldBindingOptions(Protocol): + """Shared options contract for selector-binding stages.""" + + @property + def field_mapping(self) -> Mapping[str, str]: + """Field mapping available to the binder.""" + + @property + def field_whitelist(self) -> frozenset[str] | set[str]: + """Field whitelist available to the binder.""" + + @property + def field_blacklist(self) -> frozenset[str] | set[str]: + """Field blacklist available to the binder.""" + + @property + def procedure_policy(self) -> ProcedurePolicyProtocol: + """Procedure access policy available to the binder.""" + + +def enforce_field_access_policy( + field_path: str, + *, + field_whitelist: frozenset[str] | set[str], + field_blacklist: frozenset[str] | set[str], + not_whitelisted_error_factory: callable, + blacklisted_error_factory: callable, +) -> None: + """Enforces whitelist/blacklist field access rules.""" + if field_path in field_blacklist: + raise blacklisted_error_factory( + f"Field {field_path!r} is blocked", + ) + if field_whitelist and field_path not in field_whitelist: + raise not_whitelisted_error_factory( + f"Field {field_path!r} is not allowed", + ) + + +def enforce_function_access_policy( + function_name: str, + *, + procedure_policy: ProcedurePolicyProtocol, + not_whitelisted_error_factory: callable, + blacklisted_error_factory: callable, +) -> None: + """Enforces whitelist/blacklist procedure access rules.""" + if procedure_policy.is_blacklisted(function_name): + raise blacklisted_error_factory( + f"Function {function_name!r} is blacklisted", + ) + if not procedure_policy.is_whitelisted(function_name): + raise not_whitelisted_error_factory( + f"Function {function_name!r} is not whitelisted", + ) diff --git a/src/pyrsql/_validation.py b/src/pyrsql/_validation.py new file mode 100644 index 0000000..7567de9 --- /dev/null +++ b/src/pyrsql/_validation.py @@ -0,0 +1,16 @@ +"""Small shared validation helpers.""" + +from __future__ import annotations + + +def validate_positive_int(value: int, *, field_name: str) -> None: + """Validates one strictly positive integer field. + + Raises: + TypeError: If the value is not an integer. + ValueError: If the value is not greater than zero. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{field_name} must be an integer.") + if value <= 0: + raise ValueError(f"{field_name} must be greater than 0.") diff --git a/src/pyrsql/core/compiler.py b/src/pyrsql/core/compiler.py index cc5083c..ca11b27 100644 --- a/src/pyrsql/core/compiler.py +++ b/src/pyrsql/core/compiler.py @@ -1,127 +1,45 @@ -"""ORM-neutral compilation result objects.""" +"""ORM-neutral compilation result wrapper.""" from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar +from typing import Protocol, TypeAlias, TypeVar import msgspec -if TYPE_CHECKING: - from pyrsql.orms.base import ( - CompiledPageRequest, - CompiledQuery, - CompiledSort, - ) - _TargetT = TypeVar("_TargetT") _ModelT = TypeVar("_ModelT") -def _validate_orm_name(orm_name: str, *, context: str) -> None: - """Validates one compilation result ORM name. - - Raises: - ValueError: If the ORM name is empty. - """ - if not orm_name: - raise ValueError(f"{context} orm_name cannot be empty.") - - -class CompilationResult(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """Wraps an ORM-specific compiled query object. - - Attributes: - orm_name: Name of the ORM used to produce the compilation. - compiled_query: ORM-specific compiled query payload. - """ - - orm_name: str - compiled_query: CompiledQuery - - def __post_init__(self) -> None: - """Validates compilation result invariants.""" - _validate_orm_name(self.orm_name, context="Compilation result") +class CompiledArtifact(Protocol): + """Structural contract for compiled ORM artifacts.""" def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies the compiled query to an ORM-specific target. - - Args: - target: ORM-specific target to mutate. - model: ORM model class used to resolve the query. - - Returns: - The value returned by the compiled query application. - """ - return self.compiled_query.apply(target=target, model=model) + """Applies a compiled artifact to an ORM-specific target.""" -class SortCompilationResult( - msgspec.Struct, - frozen=True, - gc=False, - kw_only=True, -): - """Wraps an ORM-specific compiled sort object. +class CompilationResult(msgspec.Struct, frozen=True, gc=False, kw_only=True): + """Wraps one ORM-specific compiled artifact. Attributes: orm_name: Name of the ORM used to produce the compilation. - compiled_sort: ORM-specific compiled sort payload. + compiled: ORM-specific compiled payload implementing ``apply``. """ orm_name: str - compiled_sort: CompiledSort - - def __post_init__(self) -> None: - """Validates sort compilation result invariants.""" - _validate_orm_name( - self.orm_name, - context="Sort compilation result", - ) + compiled: CompiledArtifact def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies the compiled sort to an ORM-specific target. + """Applies the compiled artifact to an ORM-specific target. Args: target: ORM-specific target to mutate. - model: ORM model class used to resolve the sort. + model: ORM model class used to resolve the compilation. Returns: - The value returned by the compiled sort application. + The value returned by the compiled artifact application. """ - return self.compiled_sort.apply(target=target, model=model) - - -class PageCompilationResult( - msgspec.Struct, - frozen=True, - gc=False, - kw_only=True, -): - """Wraps an ORM-specific compiled page request object. - - Attributes: - orm_name: Name of the ORM used to produce the compilation. - compiled_page: ORM-specific compiled page payload. - """ + return self.compiled.apply(target=target, model=model) - orm_name: str - compiled_page: CompiledPageRequest - - def __post_init__(self) -> None: - """Validates page compilation result invariants.""" - _validate_orm_name( - self.orm_name, - context="Page compilation result", - ) - def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies the compiled page request to an ORM-specific target. - - Args: - target: ORM-specific target to mutate. - model: ORM model class used to resolve the page request. - - Returns: - The value returned by the compiled page application. - """ - return self.compiled_page.apply(target=target, model=model) +SortCompilationResult: TypeAlias = CompilationResult +PageCompilationResult: TypeAlias = CompilationResult diff --git a/src/pyrsql/core/conversion.py b/src/pyrsql/core/conversion.py index 5547139..e81effe 100644 --- a/src/pyrsql/core/conversion.py +++ b/src/pyrsql/core/conversion.py @@ -7,7 +7,7 @@ from decimal import Decimal from enum import Enum from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias from uuid import UUID import msgspec @@ -15,14 +15,39 @@ if TYPE_CHECKING: from collections.abc import Mapping -ValueConverter = Callable[[str], object] +_JSON_DICT_DECODER = msgspec.json.Decoder(type=dict) +_JSON_LIST_DECODER = msgspec.json.Decoder(type=list) + +RawValue: TypeAlias = str +ConvertedValue: TypeAlias = Any +TargetType: TypeAlias = type[Any] +ValueConverter: TypeAlias = Callable[[RawValue], ConvertedValue] class ValueConversionError(ValueError): """Raised when a raw RSQL value cannot be converted.""" -def _convert_bool(raw_value: str) -> bool: +def _build_conversion_error( + raw_value: RawValue, + target_type: TargetType, + *, + cause: Exception | None = None, +) -> ValueConversionError: + """Builds one normalized conversion error instance. + + Returns: + A normalized value conversion error. + """ + error = ValueConversionError( + f"Failed to convert {raw_value!r} to {target_type.__name__}.", + ) + if cause is not None: + error.__cause__ = cause + return error + + +def _convert_bool(raw_value: RawValue) -> bool: """Converts a string to bool using explicit accepted values. Returns: @@ -42,14 +67,14 @@ def _convert_bool(raw_value: str) -> bool: ) -def _convert_datetime(raw_value: str) -> dt.datetime: +def _convert_datetime(raw_value: RawValue) -> dt.datetime: """Converts a string into datetime with LocalDate-style fallback. Returns: The converted datetime value. Raises: - ValueConversionError: If the input cannot be parsed as datetime or + _build_conversion_error: If the input cannot be parsed as datetime or ISO date. """ try: @@ -63,14 +88,16 @@ def _convert_datetime(raw_value: str) -> dt.datetime: try: parsed_date = dt.date.fromisoformat(raw_value) except ValueError as date_error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to datetime.", + raise _build_conversion_error( + raw_value, + dt.datetime, + cause=date_error, ) from date_error return dt.datetime.combine(parsed_date, dt.time.min) def _build_msgspec_converter( - target_type: type[Any], + target_type: TargetType, ) -> ValueConverter: """Builds one msgspec-backed scalar converter. @@ -78,7 +105,7 @@ def _build_msgspec_converter( A converter that validates and coerces a string via ``msgspec``. """ - def converter(raw_value: str) -> object: + def converter(raw_value: RawValue) -> ConvertedValue: try: return msgspec.convert( raw_value, @@ -86,8 +113,10 @@ def converter(raw_value: str) -> object: strict=False, ) except msgspec.ValidationError as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", + raise _build_conversion_error( + raw_value, + target_type, + cause=error, ) from error return converter @@ -96,7 +125,7 @@ def converter(raw_value: str) -> object: class ValueConverterRegistry(msgspec.Struct, frozen=True, gc=False): """Immutable registry of orm-neutral string-to-type converters.""" - converters: Mapping[type[Any], ValueConverter] + converters: Mapping[TargetType, ValueConverter] def __post_init__(self) -> None: """Normalizes the converter mapping into an immutable view.""" @@ -108,7 +137,7 @@ def __post_init__(self) -> None: def with_converter( self, - target_type: type[Any], + target_type: TargetType, converter: ValueConverter, ) -> ValueConverterRegistry: """Returns a new registry with one additional converter. @@ -122,75 +151,115 @@ def with_converter( def convert( self, - raw_value: str, - target_type: type[Any] | None, - ) -> object: + raw_value: RawValue, + target_type: TargetType | None, + ) -> ConvertedValue: """Converts a raw string into the requested target type. Returns: The converted value, or the raw string when no target type is provided. - - Raises: - ValueConversionError: If conversion fails or the target type is - unsupported. """ if target_type is None: return raw_value converter = self._find_registered_converter(target_type) if converter is not None: - try: - return converter(raw_value) - except Exception as error: # pragma: no cover - defensive wrap - raise ValueConversionError( - f"Failed to convert {raw_value!r} to " - f"{target_type.__name__}.", - ) from error - - try: - if issubclass(target_type, Enum): - return self._convert_enum(raw_value, target_type) - if issubclass(target_type, str): - return raw_value - if issubclass(target_type, dict): - return self._convert_json_container(raw_value, target_type) - if issubclass(target_type, list): - return self._convert_json_container(raw_value, target_type) - except TypeError as error: # pragma: no cover - invalid type object - raise ValueConversionError( - f"Unsupported target type {target_type!r}.", - ) from error - - return self._construct_from_string(raw_value, target_type) + return self._convert_with_registered_converter( + raw_value, + target_type, + converter, + ) + return self._convert_without_registered_converter( + raw_value, + target_type, + ) def _find_registered_converter( self, - target_type: type[Any], + target_type: TargetType, ) -> ValueConverter | None: """Finds the most specific registered converter for a type. Returns: The most specific registered converter, or ``None``. """ + direct_converter = self.converters.get(target_type) + if direct_converter is not None: + return direct_converter for candidate in target_type.__mro__: + if candidate is target_type: + continue converter = self.converters.get(candidate) if converter is not None: return converter return None + @staticmethod + def _convert_with_registered_converter( + raw_value: RawValue, + target_type: TargetType, + converter: ValueConverter, + ) -> ConvertedValue: + """Converts one value through a registered converter. + + Returns: + The converted value from the registered converter. + + Raises: + ValueConversionError: If the converter fails. + _build_conversion_error: If the converter raises an unexpected + exception. + """ + try: + return converter(raw_value) + except ValueConversionError: + raise + except Exception as error: # pragma: no cover - defensive wrap + raise _build_conversion_error( + raw_value, + target_type, + cause=error, + ) from error + + def _convert_without_registered_converter( + self, + raw_value: RawValue, + target_type: TargetType, + ) -> ConvertedValue: + """Converts one value through built-in fallback strategies. + + Returns: + The converted value from built-in fallback strategies. + + Raises: + ValueConversionError: If conversion fails or the type is invalid. + """ + try: + if issubclass(target_type, Enum): + return self._convert_enum(raw_value, target_type) + if issubclass(target_type, str): + return raw_value + if issubclass(target_type, dict) or issubclass(target_type, list): + return self._convert_json_container(raw_value, target_type) + except TypeError as error: # pragma: no cover - invalid type object + raise ValueConversionError( + f"Unsupported target type {target_type!r}.", + ) from error + return self._construct_from_string(raw_value, target_type) + @staticmethod def _convert_enum( - raw_value: str, - target_type: type[Any], - ) -> object: + raw_value: RawValue, + target_type: TargetType, + ) -> ConvertedValue: """Converts a string into an enum member by name first. Returns: The resolved enum member. Raises: - ValueConversionError: If no enum member matches the raw value. + _build_conversion_error: If no enum member matches the raw value. """ try: return target_type[raw_value] @@ -198,60 +267,68 @@ def _convert_enum( try: return target_type(raw_value) except ValueError as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to " - f"{target_type.__name__}.", + raise _build_conversion_error( + raw_value, + target_type, + cause=error, ) from error @staticmethod def _construct_from_string( - raw_value: str, - target_type: type[Any], - ) -> object: + raw_value: RawValue, + target_type: TargetType, + ) -> ConvertedValue: """Attempts a plain constructor-based conversion as a fallback. Returns: The converted value produced by the target type constructor. Raises: - ValueConversionError: If the target type constructor fails. + _build_conversion_error: If the target type constructor fails. """ try: return target_type(raw_value) except Exception as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", + raise _build_conversion_error( + raw_value, + target_type, + cause=error, ) from error @staticmethod def _convert_json_container( - raw_value: str, - target_type: type[Any], - ) -> object: + raw_value: RawValue, + target_type: TargetType, + ) -> ConvertedValue: """Converts a JSON string into a mapping or sequence container. Returns: The converted mapping or sequence value. Raises: - ValueConversionError: If the JSON payload is invalid, has the wrong - container shape, or cannot be rewrapped into the target type. + ValueConversionError: If the target type is not a supported JSON + container type. + _build_conversion_error: If decoding or rewrapping fails. """ - expected_type: type[Any] + expected_type: TargetType if issubclass(target_type, dict): expected_type = dict + decoder = _JSON_DICT_DECODER elif issubclass(target_type, list): expected_type = list + decoder = _JSON_LIST_DECODER else: # pragma: no cover - guarded by convert() raise ValueConversionError( f"Unsupported target type {target_type!r}.", ) try: - decoded = msgspec.json.decode(raw_value, type=expected_type) - except msgspec.ValidationError as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", + decoded = decoder.decode(raw_value) + except (msgspec.DecodeError, msgspec.ValidationError) as error: + raise _build_conversion_error( + raw_value, + target_type, + cause=error, ) from error if target_type is expected_type: @@ -259,8 +336,10 @@ def _convert_json_container( try: return target_type(decoded) except Exception as error: - raise ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", + raise _build_conversion_error( + raw_value, + target_type, + cause=error, ) from error @@ -299,6 +378,10 @@ def resolve( Returns: The most specific configured converter, or ``None``. + + Resolution precedence: + 1. model-scoped converter by ``model`` + ``field_name`` + 2. global field-path converter by ``field_path`` """ if model is not None and field_name is not None: model_converters = self.model_field_converters.get(model) diff --git a/src/pyrsql/core/custom.py b/src/pyrsql/core/custom.py index c1b0acf..49a1bbe 100644 --- a/src/pyrsql/core/custom.py +++ b/src/pyrsql/core/custom.py @@ -2,12 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any import msgspec -if TYPE_CHECKING: - from pyrsql.parsing.operators import ComparisonOperator +from pyrsql.parsing.operators import ComparisonOperator + +ArgumentType = type[Any] class CustomPredicateDefinition( @@ -16,7 +17,27 @@ class CustomPredicateDefinition( gc=False, kw_only=True, ): - """Defines a custom predicate independently of any orm.""" + """Defines one ORM-neutral custom predicate contract. + + Attributes: + operator: The query-language operator exposed to users. + argument_type: Runtime type used to coerce raw predicate arguments + before backend-specific lowering. + """ operator: ComparisonOperator - argument_type: type[Any] + argument_type: ArgumentType + + def __post_init__(self) -> None: + """Validates custom predicate invariants. + + Raises: + TypeError: If the operator or argument type has the wrong runtime + type. + """ + if not isinstance(self.operator, ComparisonOperator): + raise TypeError( + "operator must be a ComparisonOperator instance.", + ) + if not isinstance(self.argument_type, type): + raise TypeError("argument_type must be a runtime type.") diff --git a/src/pyrsql/core/field_policy.py b/src/pyrsql/core/field_policy.py index 6cb731e..e02ee41 100644 --- a/src/pyrsql/core/field_policy.py +++ b/src/pyrsql/core/field_policy.py @@ -1,7 +1,8 @@ -"""Shared field mapping and access-policy helpers.""" +"""Shared runtime field access and model-mapping helpers.""" from __future__ import annotations +from types import MappingProxyType from typing import TYPE_CHECKING, Any import msgspec @@ -11,10 +12,9 @@ class FieldPolicySet(msgspec.Struct, frozen=True, gc=False): - """ORM-neutral field mapping and access-policy configuration. + """ORM-neutral runtime field access and model-mapping configuration. Attributes: - field_mapping: Global selector-to-field mapping. field_whitelist: Globally allowed field paths. field_blacklist: Globally blocked field paths. model_field_mapping: Model-specific selector-to-field mappings. @@ -22,19 +22,60 @@ class FieldPolicySet(msgspec.Struct, frozen=True, gc=False): model_field_blacklist: Model-specific blocked field names. """ - field_mapping: Mapping[str, str] field_whitelist: frozenset[str] field_blacklist: frozenset[str] model_field_mapping: Mapping[type[Any], Mapping[str, str]] model_field_whitelist: Mapping[type[Any], frozenset[str]] model_field_blacklist: Mapping[type[Any], frozenset[str]] + def __post_init__(self) -> None: + """Normalizes policy containers into immutable views.""" + msgspec.structs.force_setattr( + self, + "field_whitelist", + frozenset(self.field_whitelist), + ) + msgspec.structs.force_setattr( + self, + "field_blacklist", + frozenset(self.field_blacklist), + ) + msgspec.structs.force_setattr( + self, + "model_field_mapping", + MappingProxyType( + { + model: MappingProxyType(dict(mapping)) + for model, mapping in self.model_field_mapping.items() + }, + ), + ) + msgspec.structs.force_setattr( + self, + "model_field_whitelist", + MappingProxyType( + { + model: frozenset(fields) + for model, fields in self.model_field_whitelist.items() + }, + ), + ) + msgspec.structs.force_setattr( + self, + "model_field_blacklist", + MappingProxyType( + { + model: frozenset(fields) + for model, fields in self.model_field_blacklist.items() + }, + ), + ) + @property def is_empty(self) -> bool: - """Whether the policy carries no active restrictions.""" + """Whether the policy carries no runtime mapping or access rules.""" return not any( ( - self.field_mapping, self.field_whitelist, self.field_blacklist, self.model_field_mapping, @@ -58,6 +99,8 @@ def map_model_field( The mapped selector segment, or the original value when no mapping is configured. """ + if not self.model_field_mapping: + return selector mapping = self.model_field_mapping.get(model) if mapping is None: return selector @@ -72,10 +115,12 @@ def validate_global_field_access(self, field_path: str) -> None: Raises: ValueError: If the field path is not allowed or is blocked. """ - if self.field_whitelist and field_path not in self.field_whitelist: - raise ValueError(f"Field {field_path!r} is not allowed.") + if not self.field_whitelist and not self.field_blacklist: + return if field_path in self.field_blacklist: raise ValueError(f"Field {field_path!r} is blocked.") + if self.field_whitelist and field_path not in self.field_whitelist: + raise ValueError(f"Field {field_path!r} is not allowed.") def validate_model_field_access( self, @@ -91,18 +136,19 @@ def validate_model_field_access( Raises: ValueError: If the field name is not allowed or is blocked. """ + if not self.model_field_whitelist and not self.model_field_blacklist: + return + blacklist = self.model_field_blacklist.get(model) + if blacklist is not None and field_name in blacklist: + raise ValueError(f"Field {model.__name__}.{field_name} is blocked.") whitelist = self.model_field_whitelist.get(model) if whitelist is not None and field_name not in whitelist: raise ValueError( f"Field {model.__name__}.{field_name} is not allowed.", ) - blacklist = self.model_field_blacklist.get(model) - if blacklist is not None and field_name in blacklist: - raise ValueError(f"Field {model.__name__}.{field_name} is blocked.") DEFAULT_FIELD_POLICY_SET = FieldPolicySet( - field_mapping={}, field_whitelist=frozenset(), field_blacklist=frozenset(), model_field_mapping={}, diff --git a/src/pyrsql/core/json/__init__.py b/src/pyrsql/core/json/__init__.py index 3f4d110..a409a8c 100644 --- a/src/pyrsql/core/json/__init__.py +++ b/src/pyrsql/core/json/__init__.py @@ -7,15 +7,10 @@ ) from pyrsql.core.json.path import JSONPath from pyrsql.core.json.query import JSONPathComparison -from pyrsql.core.json.values import ( - DEFAULT_JSON_SCALAR_NORMALIZER, - JSONScalarNormalizer, - JSONScalarValue, -) +from pyrsql.core.json.values import JSONScalarNormalizer, JSONScalarValue __all__ = [ "DEFAULT_JSON_OPTIONS", - "DEFAULT_JSON_SCALAR_NORMALIZER", "JSONOptions", "JSONPath", "JSONPathComparison", diff --git a/src/pyrsql/core/json/options.py b/src/pyrsql/core/json/options.py index ef6366d..f757143 100644 --- a/src/pyrsql/core/json/options.py +++ b/src/pyrsql/core/json/options.py @@ -1,13 +1,17 @@ """ORM-neutral JSON options.""" +from __future__ import annotations + from collections.abc import Mapping from enum import Enum import re from types import MappingProxyType +from typing import TypeAlias import msgspec _SQL_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +SortFieldTypeMap: TypeAlias = Mapping[str, "JSONSortScalarType"] class JSONSortScalarType(str, Enum): @@ -38,46 +42,45 @@ class JSONOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): path_exists_function: str = "jsonb_path_exists" path_exists_tz_function: str = "jsonb_path_exists_tz" use_datetime: bool = False - sort_field_types: Mapping[str, JSONSortScalarType] = MappingProxyType({}) + sort_field_types: SortFieldTypeMap = MappingProxyType({}) def __post_init__(self) -> None: - """Validates configured SQL function names. - - Raises: - TypeError: If ``sort_field_types`` is not a mapping. - """ - self._validate_sql_identifier( - self.path_exists_function, - field_name="path_exists_function", + """Validates and normalizes configured JSON options.""" + msgspec.structs.force_setattr( + self, + "path_exists_function", + self._normalize_sql_identifier( + self.path_exists_function, + field_name="path_exists_function", + ), ) - self._validate_sql_identifier( - self.path_exists_tz_function, - field_name="path_exists_tz_function", + msgspec.structs.force_setattr( + self, + "path_exists_tz_function", + self._normalize_sql_identifier( + self.path_exists_tz_function, + field_name="path_exists_tz_function", + ), ) - if not isinstance(self.sort_field_types, Mapping): - raise TypeError("sort_field_types must be a mapping.") msgspec.structs.force_setattr( self, "sort_field_types", - MappingProxyType( - { - self._normalize_sort_field_path(path): ( - sort_type - if isinstance(sort_type, JSONSortScalarType) - else JSONSortScalarType(sort_type) - ) - for path, sort_type in self.sort_field_types.items() - }, - ), + self._normalize_sort_field_types(self.sort_field_types), ) @staticmethod - def _validate_sql_identifier(value: str, *, field_name: str) -> None: + def _normalize_sql_identifier(value: str, *, field_name: str) -> str: """Validates one SQL function identifier. + Returns: + The validated SQL identifier. + Raises: + TypeError: If the identifier is not a string. ValueError: If the identifier is empty, padded, or invalid. """ + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string.") if not value: raise ValueError(f"{field_name} cannot be empty.") if value != value.strip(): @@ -88,6 +91,33 @@ def _validate_sql_identifier(value: str, *, field_name: str) -> None: raise ValueError( f"{field_name} must be a valid SQL identifier.", ) + return value + + @classmethod + def _normalize_sort_field_types( + cls, + sort_field_types: SortFieldTypeMap, + ) -> SortFieldTypeMap: + """Validates and normalizes JSON sort scalar type configuration. + + Returns: + An immutable mapping of normalized JSON sort field types. + + Raises: + TypeError: If ``sort_field_types`` is not a mapping. + """ + if not isinstance(sort_field_types, Mapping): + raise TypeError("sort_field_types must be a mapping.") + return MappingProxyType( + { + cls._normalize_sort_field_path(path): ( + sort_type + if isinstance(sort_type, JSONSortScalarType) + else JSONSortScalarType(sort_type) + ) + for path, sort_type in sort_field_types.items() + }, + ) @staticmethod def _normalize_sort_field_path(path: str) -> str: @@ -97,8 +127,11 @@ def _normalize_sort_field_path(path: str) -> str: The normalized JSON sort field path. Raises: + TypeError: If the configured path is not a string. ValueError: If the configured path is empty or padded. """ + if not isinstance(path, str): + raise TypeError("JSON sort field paths must be strings.") if not path: raise ValueError("JSON sort field paths cannot be empty.") if path != path.strip(): diff --git a/src/pyrsql/core/json/path.py b/src/pyrsql/core/json/path.py index 9e5c329..556060b 100644 --- a/src/pyrsql/core/json/path.py +++ b/src/pyrsql/core/json/path.py @@ -1,10 +1,13 @@ """Immutable JSON path primitives.""" +from __future__ import annotations + import re import msgspec _SIMPLE_JSONPATH_SEGMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_JSON_ENCODER = msgspec.json.Encoder() class JSONPath(msgspec.Struct, frozen=True, gc=False, kw_only=True): @@ -19,27 +22,22 @@ class JSONPath(msgspec.Struct, frozen=True, gc=False, kw_only=True): _postgresql_jsonpath: str = "$" def __post_init__(self) -> None: - """Validates path segments. - - Raises: - ValueError: If any segment is empty or padded with whitespace. - """ - for segment in self.segments: - if not segment: - raise ValueError("JSON path segments cannot be empty.") - if segment != segment.strip(): - raise ValueError( - "JSON path segments must not contain outer whitespace.", - ) + """Materializes derived cached path representations.""" + normalized_segments = tuple(self.segments) + msgspec.structs.force_setattr( + self, + "segments", + normalized_segments, + ) msgspec.structs.force_setattr( self, "_dot_path", - ".".join(self.segments), + ".".join(normalized_segments), ) msgspec.structs.force_setattr( self, "_postgresql_jsonpath", - self._build_postgresql_jsonpath(), + self._build_postgresql_jsonpath(normalized_segments), ) @property @@ -67,17 +65,19 @@ def to_postgresql_jsonpath(self) -> str: """ return self._postgresql_jsonpath - def _build_postgresql_jsonpath(self) -> str: + def _build_postgresql_jsonpath( + self, + segments: tuple[str, ...], + ) -> str: """Builds one PostgreSQL jsonpath root expression. Returns: A PostgreSQL ``jsonpath`` string rooted at ``$``. """ - if self.is_root: + if not segments: return "$" return "$" + "".join( - self._render_postgresql_segment(segment) - for segment in self.segments + self._render_postgresql_segment(segment) for segment in segments ) @staticmethod @@ -89,4 +89,4 @@ def _render_postgresql_segment(segment: str) -> str: """ if _SIMPLE_JSONPATH_SEGMENT_PATTERN.fullmatch(segment) is not None: return f".{segment}" - return "." + msgspec.json.encode(segment).decode() + return "." + _JSON_ENCODER.encode(segment).decode() diff --git a/src/pyrsql/core/json/query.py b/src/pyrsql/core/json/query.py index ebf9f67..a38ded6 100644 --- a/src/pyrsql/core/json/query.py +++ b/src/pyrsql/core/json/query.py @@ -8,15 +8,17 @@ from pyrsql.core.json.values import ( DEFAULT_JSON_SCALAR_NORMALIZER, + JSONScalarValue, ) if TYPE_CHECKING: from pyrsql.core.json.path import JSONPath from pyrsql.core.json.values import ( JSONScalarNormalizer, - JSONScalarValue, ) +RawJSONArgument = tuple[str, bool] + class JSONPathComparison( msgspec.Struct, @@ -30,26 +32,13 @@ class JSONPathComparison( operator_name: str values: tuple[JSONScalarValue, ...] - def __post_init__(self) -> None: - """Validates logical JSON comparison invariants. - - Raises: - ValueError: If the operator name is empty or padded. - """ - if not self.operator_name: - raise ValueError("operator_name cannot be empty.") - if self.operator_name != self.operator_name.strip(): - raise ValueError( - "operator_name must not contain outer whitespace.", - ) - @classmethod def from_raw_arguments( cls, *, path: JSONPath, operator_name: str, - raw_arguments: tuple[tuple[str, bool], ...], + raw_arguments: tuple[RawJSONArgument, ...], normalizer: JSONScalarNormalizer | None = None, ) -> JSONPathComparison: """Builds a normalized JSON comparison from raw RSQL arguments. diff --git a/src/pyrsql/core/json/values.py b/src/pyrsql/core/json/values.py index 1055a86..cd93bf0 100644 --- a/src/pyrsql/core/json/values.py +++ b/src/pyrsql/core/json/values.py @@ -57,9 +57,13 @@ def normalize( case _: pass if _INTEGER_PATTERN.fullmatch(raw_value): - return self._from_python_value(int(raw_value)) + parsed_integer = self._try_parse_json_number(raw_value) + if parsed_integer is not None: + return self._from_python_value(parsed_integer) if _FLOAT_PATTERN.fullmatch(raw_value): - return self._from_python_value(float(raw_value)) + parsed_float = self._try_parse_json_number(raw_value) + if parsed_float is not None: + return self._from_python_value(parsed_float) return self._from_python_value(raw_value) @staticmethod @@ -77,7 +81,7 @@ def _from_python_value(value: JSONValue) -> JSONScalarValue: @staticmethod def _try_parse_json(raw_value: str) -> JSONValue | None: - """Parses quoted JSON arguments when they contain JSON values. + """Parses quoted JSON arguments when they contain containers. Returns: The parsed JSON value, or ``None`` when the argument should remain @@ -87,9 +91,24 @@ def _try_parse_json(raw_value: str) -> JSONValue | None: parsed = cast("JSONValue", _JSON_DECODER.decode(raw_value)) except msgspec.DecodeError: return None - if isinstance(parsed, str): + if not isinstance(parsed, dict | list): return None return parsed + @staticmethod + def _try_parse_json_number(raw_value: str) -> int | float | None: + """Parses one unquoted JSON number using JSON number semantics. + + Returns: + The parsed numeric value, or ``None`` when the number is invalid. + """ + try: + parsed = _JSON_DECODER.decode(raw_value) + except msgspec.DecodeError: + return None + if isinstance(parsed, bool) or not isinstance(parsed, int | float): + return None + return cast("int | float", parsed) + DEFAULT_JSON_SCALAR_NORMALIZER = JSONScalarNormalizer() diff --git a/src/pyrsql/core/options.py b/src/pyrsql/core/options.py index e78e695..93ffb10 100644 --- a/src/pyrsql/core/options.py +++ b/src/pyrsql/core/options.py @@ -173,7 +173,6 @@ def _validate_like_escape_character( def _build_field_policy( *, - field_mapping: Mapping[str, str], field_whitelist: frozenset[str], field_blacklist: frozenset[str], model_field_mapping: Mapping[type[Any], Mapping[str, str]], @@ -186,7 +185,60 @@ def _build_field_policy( A compiled field policy object. """ return FieldPolicySet( - field_mapping=field_mapping, + field_whitelist=field_whitelist, + field_blacklist=field_blacklist, + model_field_mapping=model_field_mapping, + model_field_whitelist=model_field_whitelist, + model_field_blacklist=model_field_blacklist, + ) + + +def _has_field_policy_configuration( + *, + field_whitelist: frozenset[str], + field_blacklist: frozenset[str], + model_field_mapping: Mapping[type[Any], Mapping[str, str]], + model_field_whitelist: Mapping[type[Any], frozenset[str]], + model_field_blacklist: Mapping[type[Any], frozenset[str]], +) -> bool: + """Whether any runtime field-policy settings are configured. + + Returns: + ``True`` when at least one runtime field-policy option is configured. + """ + return any( + ( + field_whitelist, + field_blacklist, + model_field_mapping, + model_field_whitelist, + model_field_blacklist, + ), + ) + + +def _resolve_field_policy( + *, + field_whitelist: frozenset[str], + field_blacklist: frozenset[str], + model_field_mapping: Mapping[type[Any], Mapping[str, str]], + model_field_whitelist: Mapping[type[Any], frozenset[str]], + model_field_blacklist: Mapping[type[Any], frozenset[str]], +) -> FieldPolicySet: + """Builds one field policy, reusing the shared default when empty. + + Returns: + A normalized field policy instance. + """ + if not _has_field_policy_configuration( + field_whitelist=field_whitelist, + field_blacklist=field_blacklist, + model_field_mapping=model_field_mapping, + model_field_whitelist=model_field_whitelist, + model_field_blacklist=model_field_blacklist, + ): + return DEFAULT_FIELD_POLICY_SET + return _build_field_policy( field_whitelist=field_whitelist, field_blacklist=field_blacklist, model_field_mapping=model_field_mapping, @@ -207,6 +259,20 @@ def _build_procedure_policy( return ProcedureAccessPolicy.from_patterns(whitelist, blacklist) +def _resolve_procedure_policy( + whitelist: tuple[str, ...], + blacklist: tuple[str, ...], +) -> ProcedureAccessPolicy: + """Builds one procedure policy, reusing the shared default when empty. + + Returns: + A compiled procedure access policy instance. + """ + if not whitelist and not blacklist: + return DEFAULT_PROCEDURE_ACCESS_POLICY + return _build_procedure_policy(whitelist, blacklist) + + class QueryOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral configuration for parsing and compiling queries. @@ -274,7 +340,13 @@ def __post_init__(self) -> None: msgspec.structs.force_setattr( self, "_field_policy", - self._build_field_policy(), + _resolve_field_policy( + field_whitelist=self.field_whitelist, + field_blacklist=self.field_blacklist, + model_field_mapping=self.model_field_mapping, + model_field_whitelist=self.model_field_whitelist, + model_field_blacklist=self.model_field_blacklist, + ), ) msgspec.structs.force_setattr( self, @@ -284,7 +356,10 @@ def __post_init__(self) -> None: msgspec.structs.force_setattr( self, "_procedure_policy", - self._build_procedure_policy(), + _resolve_procedure_policy( + self.procedure_whitelist, + self.procedure_blacklist, + ), ) def _build_operator_registry(self) -> OperatorRegistry: @@ -349,32 +424,6 @@ def procedure_policy(self) -> ProcedureAccessPolicy: """ return self._procedure_policy - def _build_field_policy(self) -> FieldPolicySet: - """Builds the immutable field-policy object once. - - Returns: - The normalized field policy for this query configuration. - """ - if not any( - ( - self.field_mapping, - self.field_whitelist, - self.field_blacklist, - self.model_field_mapping, - self.model_field_whitelist, - self.model_field_blacklist, - ), - ): - return DEFAULT_FIELD_POLICY_SET - return _build_field_policy( - field_mapping=self.field_mapping, - field_whitelist=self.field_whitelist, - field_blacklist=self.field_blacklist, - model_field_mapping=self.model_field_mapping, - model_field_whitelist=self.model_field_whitelist, - model_field_blacklist=self.model_field_blacklist, - ) - def _build_field_converter_set(self) -> FieldValueConverterSet: """Builds the immutable field-converter object once. @@ -391,19 +440,6 @@ def _build_field_converter_set(self) -> FieldValueConverterSet: model_field_converters=self.model_field_value_converters, ) - def _build_procedure_policy(self) -> ProcedureAccessPolicy: - """Builds the compiled procedure policy once. - - Returns: - The compiled procedure access policy for this query configuration. - """ - if not self.procedure_whitelist and not self.procedure_blacklist: - return DEFAULT_PROCEDURE_ACCESS_POLICY - return _build_procedure_policy( - self.procedure_whitelist, - self.procedure_blacklist, - ) - class SortOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral sort options.""" @@ -430,12 +466,21 @@ def __post_init__(self) -> None: msgspec.structs.force_setattr( self, "_field_policy", - self._build_field_policy(), + _resolve_field_policy( + field_whitelist=self.field_whitelist, + field_blacklist=self.field_blacklist, + model_field_mapping=self.model_field_mapping, + model_field_whitelist=self.model_field_whitelist, + model_field_blacklist=self.model_field_blacklist, + ), ) msgspec.structs.force_setattr( self, "_procedure_policy", - self._build_procedure_policy(), + _resolve_procedure_policy( + self.procedure_whitelist, + self.procedure_blacklist, + ), ) @property @@ -456,45 +501,6 @@ def procedure_policy(self) -> ProcedureAccessPolicy: """ return self._procedure_policy - def _build_field_policy(self) -> FieldPolicySet: - """Builds the immutable field-policy object once. - - Returns: - The normalized field policy for this sort configuration. - """ - if not any( - ( - self.field_mapping, - self.field_whitelist, - self.field_blacklist, - self.model_field_mapping, - self.model_field_whitelist, - self.model_field_blacklist, - ), - ): - return DEFAULT_FIELD_POLICY_SET - return _build_field_policy( - field_mapping=self.field_mapping, - field_whitelist=self.field_whitelist, - field_blacklist=self.field_blacklist, - model_field_mapping=self.model_field_mapping, - model_field_whitelist=self.model_field_whitelist, - model_field_blacklist=self.model_field_blacklist, - ) - - def _build_procedure_policy(self) -> ProcedureAccessPolicy: - """Builds the compiled procedure policy once. - - Returns: - The compiled procedure access policy for this sort configuration. - """ - if not self.procedure_whitelist and not self.procedure_blacklist: - return DEFAULT_PROCEDURE_ACCESS_POLICY - return _build_procedure_policy( - self.procedure_whitelist, - self.procedure_blacklist, - ) - def _normalize_nested_mapping( mapping: Mapping[type[Any], Mapping[str, _NestedValueT]], diff --git a/src/pyrsql/core/page.py b/src/pyrsql/core/page.py index 048318e..b3ffb0d 100644 --- a/src/pyrsql/core/page.py +++ b/src/pyrsql/core/page.py @@ -16,6 +16,26 @@ _ModelT = TypeVar("_ModelT") +def _validate_page_number(page_number: int) -> None: + """Validates one zero-based page number. + + Raises: + ValueError: If the page number is negative. + """ + if page_number < 0: + raise ValueError("page_number must be greater than or equal to 0.") + + +def _validate_page_size(page_size: int) -> None: + """Validates one page size value. + + Raises: + ValueError: If the page size is not positive. + """ + if page_size <= 0: + raise ValueError("page_size must be greater than 0.") + + class PageRequest(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Represents an ORM-neutral pagination request. @@ -28,16 +48,9 @@ class PageRequest(msgspec.Struct, frozen=True, gc=False, kw_only=True): page_size: int def __post_init__(self) -> None: - """Validates pagination invariants. - - Raises: - ValueError: If the page number is negative or the page size is not - positive. - """ - if self.page_number < 0: - raise ValueError("page_number must be greater than or equal to 0.") - if self.page_size <= 0: - raise ValueError("page_size must be greater than 0.") + """Validates pagination invariants.""" + _validate_page_number(self.page_number) + _validate_page_size(self.page_size) @classmethod def of( @@ -76,10 +89,8 @@ def from_offset( ValueError: If the offset or limit is invalid, or if the offset does not align with the limit. """ - if offset < 0: - raise ValueError("offset must be greater than or equal to 0.") - if limit <= 0: - raise ValueError("limit must be greater than 0.") + _validate_page_number(offset) + _validate_page_size(limit) page_number, remainder = divmod(offset, limit) if remainder != 0: raise ValueError( @@ -129,7 +140,7 @@ def compile(self, *, orm: ORM) -> PageCompilationResult: compiled_page = orm.compile_page_request(self) return PageCompilationResult( orm_name=orm.name, - compiled_page=compiled_page, + compiled=compiled_page, ) def apply( @@ -149,4 +160,4 @@ def apply( Returns: The value returned by the ORM-specific apply operation. """ - return self.compile(orm=orm).apply(target=target, model=model) + return orm.compile_page_request(self).apply(target=target, model=model) diff --git a/src/pyrsql/core/procedure_policy.py b/src/pyrsql/core/procedure_policy.py index 12f4a08..50a38bf 100644 --- a/src/pyrsql/core/procedure_policy.py +++ b/src/pyrsql/core/procedure_policy.py @@ -3,13 +3,69 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import msgspec if TYPE_CHECKING: from re import Pattern +_COMPILED_PATTERN_TYPE: Final = type(re.compile(r"")) + + +def _compile_pattern(pattern: str, *, context: str) -> Pattern[str]: + """Compiles one raw regex pattern with contextual errors. + + Returns: + One compiled regular expression. + + Raises: + ValueError: If the regular expression is invalid. + """ + try: + return re.compile(pattern) + except re.error as error: + raise ValueError( + f"Invalid {context} regex pattern {pattern!r}.", + ) from error + + +def _compile_patterns( + patterns: tuple[str, ...], + *, + context: str, +) -> tuple[Pattern[str], ...]: + """Compiles raw regex patterns with contextual errors. + + Returns: + Compiled regular expressions in declaration order. + """ + return tuple( + _compile_pattern(pattern, context=context) for pattern in patterns + ) + + +def _normalize_compiled_patterns( + patterns: tuple[Pattern[str], ...], + *, + context: str, +) -> tuple[Pattern[str], ...]: + """Normalizes and validates compiled regex patterns. + + Returns: + The validated compiled regular expressions. + + Raises: + TypeError: If any pattern is not a compiled regular expression. + """ + normalized = tuple(patterns) + for pattern in normalized: + if not isinstance(pattern, _COMPILED_PATTERN_TYPE): + raise TypeError( + f"{context} must contain compiled regular expressions.", + ) + return normalized + class ProcedureAccessPolicy( msgspec.Struct, @@ -30,6 +86,25 @@ class ProcedureAccessPolicy( whitelist_patterns: tuple[Pattern[str], ...] blacklist_patterns: tuple[Pattern[str], ...] + def __post_init__(self) -> None: + """Normalizes and validates compiled regex patterns.""" + msgspec.structs.force_setattr( + self, + "whitelist_patterns", + _normalize_compiled_patterns( + self.whitelist_patterns, + context="whitelist_patterns", + ), + ) + msgspec.structs.force_setattr( + self, + "blacklist_patterns", + _normalize_compiled_patterns( + self.blacklist_patterns, + context="blacklist_patterns", + ), + ) + @classmethod def from_patterns( cls, @@ -46,11 +121,13 @@ def from_patterns( A compiled procedure access policy. """ return cls( - whitelist_patterns=tuple( - re.compile(pattern) for pattern in whitelist + whitelist_patterns=_compile_patterns( + whitelist, + context="whitelist", ), - blacklist_patterns=tuple( - re.compile(pattern) for pattern in blacklist + blacklist_patterns=_compile_patterns( + blacklist, + context="blacklist", ), ) @@ -60,6 +137,8 @@ def is_whitelisted(self, procedure_name: str) -> bool: Returns: ``True`` when the procedure matches at least one whitelist pattern. """ + if not self.whitelist_patterns: + return False return self._matches_any(procedure_name, self.whitelist_patterns) def is_blacklisted(self, procedure_name: str) -> bool: @@ -68,6 +147,8 @@ def is_blacklisted(self, procedure_name: str) -> bool: Returns: ``True`` when the procedure matches at least one blacklist pattern. """ + if not self.blacklist_patterns: + return False return self._matches_any(procedure_name, self.blacklist_patterns) @staticmethod @@ -80,7 +161,10 @@ def _matches_any( Returns: ``True`` when the value fully matches at least one pattern. """ - return any(pattern.fullmatch(procedure_name) for pattern in patterns) + for pattern in patterns: + if pattern.fullmatch(procedure_name) is not None: + return True + return False DEFAULT_PROCEDURE_ACCESS_POLICY = ProcedureAccessPolicy( diff --git a/src/pyrsql/core/query.py b/src/pyrsql/core/query.py index 66fac52..0f126fc 100644 --- a/src/pyrsql/core/query.py +++ b/src/pyrsql/core/query.py @@ -22,47 +22,6 @@ _DEFAULT_QUERY_OPTIONS = QueryOptions() -def _resolve_query_options( - options: QueryOptions | None, -) -> QueryOptions: - """Returns the provided options or the shared immutable default. - - Returns: - The provided options, or the shared default when omitted. - """ - return options or _DEFAULT_QUERY_OPTIONS - - -def _parse_query_expression( - query_text: str, - *, - options: QueryOptions, -) -> Expression: - """Parses raw query text into a syntax tree. - - Returns: - The parsed query expression tree. - """ - return Parser( - query_text, - limits=options.parse_limits, - operator_registry=options.operator_registry, - ).parse() - - -def _bind_query_expression( - expression: Expression, - *, - options: QueryOptions, -) -> BoundComparison | BoundLogical: - """Binds a syntax tree into logical query IR. - - Returns: - The bound logical query IR. - """ - return SemanticBinder(options).bind(expression) - - class Query(msgspec.Struct, frozen=True, gc=False): """Represents a parsed ORM-neutral query request. @@ -97,7 +56,7 @@ def parse( Returns: A parsed query object. """ - resolved_options = _resolve_query_options(options) + resolved_options = options or _DEFAULT_QUERY_OPTIONS expression = cls.parse_expression(query_text, options=resolved_options) bound_expression = cls.bind_expression( expression, @@ -125,7 +84,11 @@ def parse_expression( Returns: The parsed syntax tree. """ - return _parse_query_expression(query_text, options=options) + return Parser( + query_text, + limits=options.parse_limits, + operator_registry=options.operator_registry, + ).parse() @staticmethod def bind_expression( @@ -142,7 +105,7 @@ def bind_expression( Returns: The bound logical query IR. """ - return _bind_query_expression(expression, options=options) + return SemanticBinder(options).bind(expression) def compile(self, *, orm: ORM) -> CompilationResult: """Compiles the query using the provided ORM. @@ -156,7 +119,7 @@ def compile(self, *, orm: ORM) -> CompilationResult: compiled_query = orm.compile_query(self) return CompilationResult( orm_name=orm.name, - compiled_query=compiled_query, + compiled=compiled_query, ) def apply( @@ -176,4 +139,4 @@ def apply( Returns: The value returned by the ORM-specific apply operation. """ - return self.compile(orm=orm).apply(target=target, model=model) + return orm.compile_query(self).apply(target=target, model=model) diff --git a/src/pyrsql/core/sort.py b/src/pyrsql/core/sort.py index bae86f4..551ffbf 100644 --- a/src/pyrsql/core/sort.py +++ b/src/pyrsql/core/sort.py @@ -22,48 +22,6 @@ _DEFAULT_SORT_OPTIONS = SortOptions() -def _resolve_sort_options( - options: SortOptions | None, -) -> SortOptions: - """Returns the provided options or the shared immutable default. - - Returns: - The provided options, or the shared default when omitted. - """ - return options or _DEFAULT_SORT_OPTIONS - - -def _parse_sort_fields( - sort_text: str | None, - *, - options: SortOptions, -) -> tuple[SortField, ...]: - """Parses raw sort text into sort fields. - - Returns: - The parsed sort fields. - """ - return SortParser( - sort_text, - limits=options.sort_limits, - ).parse() - - -def _bind_sort_fields( - fields: tuple[SortField, ...], - *, - options: SortOptions, -) -> BoundSort | None: - """Binds parsed sort fields into logical sort IR. - - Returns: - The bound logical sort IR, or ``None`` when no fields were parsed. - """ - if not fields: - return None - return SortBinder(options).bind(fields) - - class Sort(msgspec.Struct, frozen=True, gc=False): """Represents an ORM-neutral parsed sort request. @@ -95,7 +53,7 @@ def parse( Returns: A parsed sort object. """ - resolved_options = _resolve_sort_options(options) + resolved_options = options or _DEFAULT_SORT_OPTIONS fields = cls.parse_fields(sort_text, options=resolved_options) bound_sort = cls.bind_fields(fields, options=resolved_options) return cls( @@ -120,7 +78,10 @@ def parse_fields( Returns: The parsed sort fields. """ - return _parse_sort_fields(sort_text, options=options) + return SortParser( + sort_text, + limits=options.sort_limits, + ).parse() @staticmethod def bind_fields( @@ -137,7 +98,9 @@ def bind_fields( Returns: The bound logical sort IR, or None when no fields were parsed. """ - return _bind_sort_fields(fields, options=options) + if not fields: + return None + return SortBinder(options).bind(fields) def compile(self, *, orm: ORM) -> SortCompilationResult: """Compiles the sort using the provided ORM. @@ -151,7 +114,7 @@ def compile(self, *, orm: ORM) -> SortCompilationResult: compiled_sort = orm.compile_sort(self) return SortCompilationResult( orm_name=orm.name, - compiled_sort=compiled_sort, + compiled=compiled_sort, ) def apply( @@ -171,4 +134,4 @@ def apply( Returns: The value returned by the ORM-specific apply operation. """ - return self.compile(orm=orm).apply(target=target, model=model) + return orm.compile_sort(self).apply(target=target, model=model) diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index 9df2000..80266f4 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -2,7 +2,7 @@ from __future__ import annotations -from threading import RLock +from threading import Lock from types import MappingProxyType from typing import TYPE_CHECKING @@ -87,7 +87,7 @@ def __init__( criteria_config or _DEFAULT_FASTAPI_CRITERIA_CONFIG ) self._criteria_dependency = CriteriaDependency(self.criteria_config) - self._cache_lock = RLock() + self._cache_lock = Lock() self._select_dependencies: dict[ SQLAlchemyModel, Callable[..., SQLAlchemySelect], @@ -212,6 +212,9 @@ def _base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: Returns: A cached base ``select(model)`` statement. """ + statement = self._base_selects.get(model) + if statement is not None: + return statement with self._cache_lock: statement = self._base_selects.get(model) if statement is not None: @@ -319,6 +322,9 @@ def select_dependency( Returns: A FastAPI dependency that yields a filtered select statement. """ + cached_dependency = self._select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency with self._cache_lock: cached_dependency = self._select_dependencies.get(model) if cached_dependency is not None: @@ -353,6 +359,9 @@ def count_select_dependency( Returns: A FastAPI dependency that yields a count select statement. """ + cached_dependency = self._count_select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency with self._cache_lock: cached_dependency = self._count_select_dependencies.get(model) if cached_dependency is not None: @@ -382,6 +391,9 @@ def paginated_select_dependency( Returns: A FastAPI dependency that yields paired list and count statements. """ + cached_dependency = self._paginated_select_dependencies.get(model) + if cached_dependency is not None: + return cached_dependency with self._cache_lock: cached_dependency = self._paginated_select_dependencies.get(model) if cached_dependency is not None: diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py index c4cb608..dd359bc 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py @@ -2,7 +2,7 @@ from __future__ import annotations -from threading import RLock +from threading import Lock from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from fastapi import Depends @@ -119,7 +119,7 @@ def __init__( self.criteria_config = criteria_config self._default_sort = default_sort self._statement_factory = statement_factory - self._cache_lock = RLock() + self._cache_lock = Lock() self._applier_dependency: ( Callable[..., Callable[[SQLAlchemySelect], SQLAlchemySelect]] | None ) = None @@ -260,6 +260,8 @@ def select_dependency(self) -> Callable[..., SQLAlchemySelect]: Returns: A FastAPI dependency that yields a filtered select statement. """ + if self._select_dependency is not None: + return self._select_dependency with self._cache_lock: if self._select_dependency is not None: return self._select_dependency @@ -295,6 +297,8 @@ def applier_dependency( Returns: A FastAPI dependency that yields a select applier callable. """ + if self._applier_dependency is not None: + return self._applier_dependency with self._cache_lock: if self._applier_dependency is not None: return self._applier_dependency @@ -314,6 +318,8 @@ def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: Returns: A FastAPI dependency that yields a count select statement. """ + if self._count_select_dependency is not None: + return self._count_select_dependency with self._cache_lock: if self._count_select_dependency is not None: return self._count_select_dependency @@ -343,6 +349,8 @@ def paginated_select_dependency( Returns: A FastAPI dependency that yields paginated SQLAlchemy statements. """ + if self._paginated_select_dependency is not None: + return self._paginated_select_dependency with self._cache_lock: if self._paginated_select_dependency is not None: return self._paginated_select_dependency diff --git a/src/pyrsql/ir/__init__.py b/src/pyrsql/ir/__init__.py index 289df19..ffd2dee 100644 --- a/src/pyrsql/ir/__init__.py +++ b/src/pyrsql/ir/__init__.py @@ -8,8 +8,6 @@ BoundFunction, BoundLiteral, BoundLogical, - BoundNode, - BoundSelectorNode, ) from pyrsql.ir.sort import BoundSort, BoundSortField @@ -20,9 +18,7 @@ "BoundFunction", "BoundLiteral", "BoundLogical", - "BoundNode", "BoundPage", - "BoundSelectorNode", "BoundSort", "BoundSortField", ] diff --git a/src/pyrsql/ir/page.py b/src/pyrsql/ir/page.py index 7f96ee1..e79290c 100644 --- a/src/pyrsql/ir/page.py +++ b/src/pyrsql/ir/page.py @@ -9,18 +9,6 @@ class BoundPage(msgspec.Struct, frozen=True, gc=False, kw_only=True): page_number: int page_size: int - def __post_init__(self) -> None: - """Validates pagination invariants. - - Raises: - ValueError: If the page number is negative or the page size is not - positive. - """ - if self.page_number < 0: - raise ValueError("page_number must be greater than or equal to 0.") - if self.page_size <= 0: - raise ValueError("page_size must be greater than 0.") - @property def offset(self) -> int: """Returns the zero-based row offset. diff --git a/src/pyrsql/ir/query.py b/src/pyrsql/ir/query.py index a54a8df..df2db58 100644 --- a/src/pyrsql/ir/query.py +++ b/src/pyrsql/ir/query.py @@ -6,13 +6,12 @@ import msgspec -from pyrsql.parsing.source import SourceSpan - if TYPE_CHECKING: from collections.abc import Iterator from pyrsql.parsing.ast import LogicalOperator from pyrsql.parsing.operators import ComparisonOperator + from pyrsql.parsing.source import SourceSpan from pyrsql.selector.ast import SelectorLiteral _MIN_LOGICAL_CHILDREN: Final = 2 @@ -23,15 +22,6 @@ class BoundNode(msgspec.Struct, frozen=True, gc=False, kw_only=True): span: SourceSpan - def __post_init__(self) -> None: - """Validates bound node invariants. - - Raises: - TypeError: If the span is not a SourceSpan. - """ - if not isinstance(self.span, SourceSpan): - raise TypeError("Bound node span must be a SourceSpan.") - def walk(self) -> Iterator[BoundNode]: """Yields this node and all nested bound nodes depth-first.""" yield self @@ -52,23 +42,6 @@ class BoundField(BoundSelectorNode, frozen=True, gc=False, kw_only=True): field_path: str segments: tuple[str, ...] - def __post_init__(self) -> None: - """Validates bound field invariants. - - Raises: - ValueError: If the raw path, field path, or segments are invalid. - """ - if not self.raw_path: - raise ValueError("Bound field raw_path cannot be empty.") - if not self.field_path: - raise ValueError("Bound field field_path cannot be empty.") - if not self.segments: - raise ValueError("Bound field must contain at least one segment.") - if tuple(self.field_path.split(".")) != self.segments: - raise ValueError( - "Bound field segments must match the resolved field_path.", - ) - class BoundLiteral(BoundSelectorNode, frozen=True, gc=False, kw_only=True): """A bound literal selector.""" @@ -82,19 +55,6 @@ class BoundFunction(BoundSelectorNode, frozen=True, gc=False, kw_only=True): function_name: str arguments: tuple[BoundSelectorNode, ...] - def __post_init__(self) -> None: - """Validates bound function invariants. - - Raises: - ValueError: If the function name or arguments are invalid. - """ - if not self.function_name: - raise ValueError("Bound function name cannot be empty.") - if not self.arguments: - raise ValueError( - "Bound function must contain at least one argument.", - ) - def walk_selectors(self) -> Iterator[BoundSelectorNode]: """Yields this selector and all nested selector nodes.""" yield self @@ -113,17 +73,6 @@ class BoundArgument(msgspec.Struct, frozen=True, gc=False, kw_only=True): quoted: bool span: SourceSpan - def __post_init__(self) -> None: - """Validates bound argument invariants. - - Raises: - TypeError: If the argument text or span has the wrong type. - """ - if not isinstance(self.text, str): - raise TypeError("Bound argument text must be a string.") - if not isinstance(self.span, SourceSpan): - raise TypeError("Bound argument span must be a SourceSpan.") - class BoundComparison(BoundNode, frozen=True, gc=False, kw_only=True): """A bound comparison node.""" @@ -138,7 +87,6 @@ def __post_init__(self) -> None: Raises: ValueError: If the arguments do not satisfy operator arity. """ - super().__post_init__() if len(self.arguments) < self.operator.minimum_arguments: raise ValueError( "Bound comparison is missing required operator arguments.", @@ -168,7 +116,6 @@ def __post_init__(self) -> None: Raises: ValueError: If the logical node has fewer than two children. """ - super().__post_init__() if len(self.children) < _MIN_LOGICAL_CHILDREN: raise ValueError( "Bound logical nodes must contain at least two children.", diff --git a/src/pyrsql/ir/sort.py b/src/pyrsql/ir/sort.py index e813f49..24889fc 100644 --- a/src/pyrsql/ir/sort.py +++ b/src/pyrsql/ir/sort.py @@ -2,10 +2,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import msgspec -from pyrsql.ir.query import BoundSelectorNode -from pyrsql.sorting.ast import SortDirection +if TYPE_CHECKING: + from pyrsql.ir.query import BoundSelectorNode + from pyrsql.sorting.ast import SortDirection class BoundSortField(msgspec.Struct, frozen=True, gc=False, kw_only=True): @@ -15,23 +18,6 @@ class BoundSortField(msgspec.Struct, frozen=True, gc=False, kw_only=True): direction: SortDirection ignore_case: bool - def __post_init__(self) -> None: - """Validates bound sort field invariants. - - Raises: - TypeError: If any field has the wrong runtime type. - """ - if not isinstance(self.selector, BoundSelectorNode): - raise TypeError( - "Bound sort field selector must be a BoundSelectorNode.", - ) - if not isinstance(self.direction, SortDirection): - raise TypeError( - "Bound sort field direction must be a SortDirection.", - ) - if not isinstance(self.ignore_case, bool): - raise TypeError("Bound sort field ignore_case must be a bool.") - class BoundSort(msgspec.Struct, frozen=True, gc=False, kw_only=True): """A bound sort request.""" diff --git a/src/pyrsql/orms/sqlalchemy/introspection.py b/src/pyrsql/orms/sqlalchemy/introspection.py index 32f7a4c..fa8f046 100644 --- a/src/pyrsql/orms/sqlalchemy/introspection.py +++ b/src/pyrsql/orms/sqlalchemy/introspection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from threading import RLock +from threading import Lock from typing import TYPE_CHECKING, Any from sqlalchemy import inspect @@ -23,11 +23,17 @@ class SQLAlchemyModelInspector: """Provides stable access to public SQLAlchemy ORM inspection APIs.""" - __slots__ = ("_attribute_cache", "_cache_lock", "_mapper_cache") + __slots__ = ( + "_attribute_cache", + "_attribute_cache_lock", + "_mapper_cache", + "_mapper_cache_lock", + ) def __init__(self) -> None: """Initializes empty caches for mapped models and attributes.""" - self._cache_lock = RLock() + self._mapper_cache_lock = Lock() + self._attribute_cache_lock = Lock() self._mapper_cache: dict[type[Any], Mapper[Any]] = {} self._attribute_cache: dict[ tuple[type[Any], str], @@ -39,27 +45,15 @@ def inspect_model(self, model: type[Any]) -> Mapper[Any]: Returns: The SQLAlchemy mapper for the model. - - Raises: - SQLAlchemyModelInspectionError: If the model is not mapped. """ - with self._cache_lock: + cached_mapper = self._mapper_cache.get(model) + if cached_mapper is not None: + return cached_mapper + with self._mapper_cache_lock: cached_mapper = self._mapper_cache.get(model) if cached_mapper is not None: return cached_mapper - try: - mapper = inspect(model) - except NoInspectionAvailable as error: - raise SQLAlchemyModelInspectionError( - f"Type {model!r} is not a SQLAlchemy mapped class.", - ) from error - if not isinstance(mapper, Mapper): - raise SQLAlchemyModelInspectionError( - f"Type {model!r} did not resolve to a SQLAlchemy ORM " - "mapper.", - ) - self._mapper_cache[model] = mapper - return mapper + return self._inspect_model_locked(model) def get_mapped_attribute( self, @@ -74,8 +68,12 @@ def get_mapped_attribute( Raises: SQLAlchemyModelInspectionError: If the attribute is not mapped. """ - with self._cache_lock: - cache_key = (model, attribute_name) + cache_key = (model, attribute_name) + cached_attribute = self._attribute_cache.get(cache_key) + if cached_attribute is not None: + return cached_attribute + + with self._attribute_cache_lock: cached_attribute = self._attribute_cache.get(cache_key) if cached_attribute is not None: return cached_attribute @@ -116,6 +114,31 @@ def get_mapped_attribute( f"{model.__name__!r}.", ) + def _inspect_model_locked(self, model: type[Any]) -> Mapper[Any]: + """Resolves and caches one mapper while the cache lock is held. + + Returns: + The SQLAlchemy mapper for the model. + + Raises: + SQLAlchemyModelInspectionError: If the model is not mapped. + """ + cached_mapper = self._mapper_cache.get(model) + if cached_mapper is not None: + return cached_mapper + try: + mapper = inspect(model) + except NoInspectionAvailable as error: + raise SQLAlchemyModelInspectionError( + f"Type {model!r} is not a SQLAlchemy mapped class.", + ) from error + if not isinstance(mapper, Mapper): + raise SQLAlchemyModelInspectionError( + f"Type {model!r} did not resolve to a SQLAlchemy ORM mapper.", + ) + self._mapper_cache[model] = mapper + return mapper + def _cache_mapped_attribute( self, cache_key: tuple[type[Any], str], diff --git a/src/pyrsql/orms/sqlalchemy/resolver.py b/src/pyrsql/orms/sqlalchemy/resolver.py index e862e7b..8d8895f 100644 --- a/src/pyrsql/orms/sqlalchemy/resolver.py +++ b/src/pyrsql/orms/sqlalchemy/resolver.py @@ -2,7 +2,7 @@ from __future__ import annotations -from threading import RLock +from threading import Lock from typing import TYPE_CHECKING, Any, Final, cast from pyrsql.core.joins import JoinHint @@ -40,7 +40,7 @@ def __init__( inspector: SQLAlchemyModelInspector | None = None, ) -> None: """Initializes the resolver with an optional shared inspector.""" - self._cache_lock = RLock() + self._cache_lock = Lock() self._inspector = inspector or SQLAlchemyModelInspector() self._default_resolution_cache: dict[ tuple[type[Any], str], @@ -60,8 +60,11 @@ def resolve( The resolved SQLAlchemy path. """ if field_policy is None or field_policy.is_empty: + cache_key = (model, field_path) + cached_path = self._default_resolution_cache.get(cache_key) + if cached_path is not None: + return cached_path with self._cache_lock: - cache_key = (model, field_path) cached_path = self._default_resolution_cache.get(cache_key) if cached_path is not None: return cached_path diff --git a/src/pyrsql/parsing/__init__.py b/src/pyrsql/parsing/__init__.py index dbdf52a..e86872b 100644 --- a/src/pyrsql/parsing/__init__.py +++ b/src/pyrsql/parsing/__init__.py @@ -6,7 +6,6 @@ Expression, LogicalNode, LogicalOperator, - Node, ) from pyrsql.parsing.diagnostics import ParseDiagnostic from pyrsql.parsing.errors import LexError, ParseError @@ -14,7 +13,7 @@ from pyrsql.parsing.limits import DEFAULT_PARSE_LIMITS, ParseLimits from pyrsql.parsing.parser import Parser from pyrsql.parsing.source import SourceSpan, SourceText -from pyrsql.parsing.tokens import Token, TokenKind +from pyrsql.parsing.tokens import TokenKind __all__ = [ "DEFAULT_PARSE_LIMITS", @@ -25,13 +24,11 @@ "Lexer", "LogicalNode", "LogicalOperator", - "Node", "ParseDiagnostic", "ParseError", "ParseLimits", "Parser", "SourceSpan", "SourceText", - "Token", "TokenKind", ] diff --git a/src/pyrsql/parsing/ast.py b/src/pyrsql/parsing/ast.py index c5bb7cc..1bd3935 100644 --- a/src/pyrsql/parsing/ast.py +++ b/src/pyrsql/parsing/ast.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum, auto -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING import msgspec @@ -14,8 +14,6 @@ from pyrsql.parsing.source import SourceSpan from pyrsql.selector.ast import SelectorNode -_MIN_LOGICAL_CHILDREN: Final = 2 - class Argument(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Represents a comparison argument.""" @@ -24,15 +22,6 @@ class Argument(msgspec.Struct, frozen=True, gc=False, kw_only=True): quoted: bool span: SourceSpan - def __post_init__(self) -> None: - """Validates argument invariants. - - Raises: - TypeError: If the argument text is not a string. - """ - if not isinstance(self.text, str): - raise TypeError("Argument text must be a string.") - class Node(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Base AST node.""" @@ -51,18 +40,6 @@ class ComparisonNode(Node, frozen=True, gc=False, kw_only=True): operator: ComparisonOperator arguments: tuple[Argument, ...] - def __post_init__(self) -> None: - """Validates comparison node invariants. - - Raises: - ValueError: If the operator requires arguments but none are - provided. - """ - if not self.arguments and self.operator.minimum_arguments > 0: - raise ValueError( - "Comparison node is missing required operator arguments.", - ) - def with_span(self, span: SourceSpan) -> ComparisonNode: """Returns a copy of the comparison with an updated span. @@ -90,17 +67,6 @@ class LogicalNode(Node, frozen=True, gc=False, kw_only=True): operator: LogicalOperator children: tuple[Expression, ...] - def __post_init__(self) -> None: - """Validates logical node invariants. - - Raises: - ValueError: If the logical node has fewer than two children. - """ - if len(self.children) < _MIN_LOGICAL_CHILDREN: - raise ValueError( - "Logical nodes must contain at least two child expressions.", - ) - def walk(self) -> Iterator[Node]: """Yields this node and all descendant syntax nodes depth-first.""" yield self diff --git a/src/pyrsql/parsing/errors.py b/src/pyrsql/parsing/errors.py index 8d8394d..639dd29 100644 --- a/src/pyrsql/parsing/errors.py +++ b/src/pyrsql/parsing/errors.py @@ -38,7 +38,10 @@ def __str__(self) -> str: Returns: The formatted parse error string. """ - return str(self.diagnostic) + return ( + f"[{self.code}] {self.message} at index {self.span.start.index} " + f"(line {self.span.start.line}, column {self.span.start.column})" + ) class LexError(ParseError): diff --git a/src/pyrsql/parsing/lexer.py b/src/pyrsql/parsing/lexer.py index c253dbb..feb4b61 100644 --- a/src/pyrsql/parsing/lexer.py +++ b/src/pyrsql/parsing/lexer.py @@ -26,18 +26,67 @@ def __init__( operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY, ) -> None: """Initializes the lexer with source text and parser limits.""" - self._source = ( - source - if isinstance(source, SourceText) - else SourceText(text=source) + self._text = self._normalize_source(source).text + self._length = len(self._text) + self._limits = self._normalize_limits(limits) + self._operator_registry = self._normalize_operator_registry( + operator_registry, ) - self._limits = limits or DEFAULT_PARSE_LIMITS - self._operator_registry = operator_registry self._index = 0 self._line = 1 self._column = 1 self._validate_source_length() + @staticmethod + def _normalize_source(source: str | SourceText) -> SourceText: + """Normalizes the lexer source input. + + Returns: + A wrapped immutable source object. + + Raises: + TypeError: If the source is neither raw text nor ``SourceText``. + """ + if isinstance(source, SourceText): + return source + if not isinstance(source, str): + raise TypeError("Lexer source must be a string or SourceText.") + return SourceText(text=source) + + @staticmethod + def _normalize_limits(limits: ParseLimits | None) -> ParseLimits: + """Normalizes optional lexer limits. + + Returns: + The provided limits, or the shared defaults. + + Raises: + TypeError: If ``limits`` is not a ``ParseLimits`` instance. + """ + if limits is None: + return DEFAULT_PARSE_LIMITS + if not isinstance(limits, type(DEFAULT_PARSE_LIMITS)): + raise TypeError("Lexer limits must be a ParseLimits instance.") + return limits + + @staticmethod + def _normalize_operator_registry( + operator_registry: OperatorRegistry, + ) -> OperatorRegistry: + """Normalizes the operator registry dependency. + + Returns: + The validated operator registry instance. + + Raises: + TypeError: If the registry is not an ``OperatorRegistry``. + """ + if not isinstance(operator_registry, type(DEFAULT_OPERATOR_REGISTRY)): + raise TypeError( + "Lexer operator_registry must be an OperatorRegistry instance.", + ) + return operator_registry + @property def limits(self) -> ParseLimits: """Returns the configured parser safety limits. @@ -137,11 +186,11 @@ def _consume_unquoted_text(self) -> Token: break if current_char in "(),;": break - if self._starts_with_operator(): + if self._match_operator_spelling(current_char) is not None: break self._advance() - lexeme = self._source.slice(start_index, self._index) + lexeme = self._text[start_index : self._index] if not lexeme: raise LexError( message=f"Unexpected character {self._peek()!r}", @@ -175,35 +224,33 @@ def _match_operator(self) -> Token | None: Returns: The matched operator token, or ``None``. """ + spelling = self._match_operator_spelling() + if spelling is None: + return None start = self._current_position() - for spelling in self._candidate_operator_spellings(): - if self._source.text.startswith(spelling, self._index): - self._advance_operator(spelling) - return self._make_token( - TokenKind.COMPARISON_OPERATOR, - spelling, - start, - ) - return None - - def _starts_with_operator(self) -> bool: - """Checks whether the current position begins an operator. - - Returns: - ``True`` when the current position can start a known operator. - """ - for spelling in self._candidate_operator_spellings(): - if self._source.text.startswith(spelling, self._index): - return True - return False + self._advance_operator(spelling) + return self._make_token( + TokenKind.COMPARISON_OPERATOR, + spelling, + start, + ) - def _candidate_operator_spellings(self) -> tuple[str, ...]: - """Returns spellings matching the current prefix character. + def _match_operator_spelling( + self, + prefix: str | None = None, + ) -> str | None: + """Returns the matching operator spelling at the current cursor. Returns: - Candidate operator spellings for the current prefix. + The matched operator spelling, or ``None``. """ - return self._operator_registry.match_candidates(self._peek()) + current_prefix = prefix or self._peek() + for spelling in self._operator_registry.match_candidates( + current_prefix, + ): + if self._text.startswith(spelling, self._index): + return spelling + return None def _skip_whitespace(self) -> None: """Skips ASCII whitespace characters.""" @@ -218,7 +265,7 @@ def _validate_source_length(self) -> None: Raises: LexError: If the source exceeds the configured length. """ - if self._source.length > self._limits.max_query_length: + if self._length > self._limits.max_query_length: position = SourcePosition(index=0, line=1, column=1) raise LexError( message=( @@ -272,7 +319,7 @@ def _advance(self) -> str: Returns: The consumed character. """ - character = self._source.text[self._index] + character = self._text[self._index] self._index += 1 if character == "\n": self._line += 1 @@ -292,7 +339,7 @@ def _peek(self) -> str: Returns: The current character. """ - return self._source.text[self._index] + return self._text[self._index] def _current_position(self) -> SourcePosition: """Returns the current source position. @@ -329,4 +376,4 @@ def _is_at_end(self) -> bool: Returns: ``True`` when the lexer cursor reached the end of input. """ - return self._index >= self._source.length + return self._index >= self._length diff --git a/src/pyrsql/parsing/limits.py b/src/pyrsql/parsing/limits.py index b1f5271..fa0c506 100644 --- a/src/pyrsql/parsing/limits.py +++ b/src/pyrsql/parsing/limits.py @@ -1,7 +1,11 @@ """Safety limits for lexing and parsing.""" +from __future__ import annotations + import msgspec +from pyrsql._validation import validate_positive_int + class ParseLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Configurable safety limits for query processing.""" @@ -14,23 +18,31 @@ class ParseLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): max_node_count: int = 1024 def __post_init__(self) -> None: - """Validates parser limit invariants. - - Raises: - ValueError: If any parser limit is non-positive. - """ - if self.max_query_length <= 0: - raise ValueError("max_query_length must be greater than 0.") - if self.max_selector_length <= 0: - raise ValueError("max_selector_length must be greater than 0.") - if self.max_argument_length <= 0: - raise ValueError("max_argument_length must be greater than 0.") - if self.max_arguments_per_list <= 0: - raise ValueError("max_arguments_per_list must be greater than 0.") - if self.max_expression_depth <= 0: - raise ValueError("max_expression_depth must be greater than 0.") - if self.max_node_count <= 0: - raise ValueError("max_node_count must be greater than 0.") + """Validates parser limit invariants.""" + validate_positive_int( + self.max_query_length, + field_name="max_query_length", + ) + validate_positive_int( + self.max_selector_length, + field_name="max_selector_length", + ) + validate_positive_int( + self.max_argument_length, + field_name="max_argument_length", + ) + validate_positive_int( + self.max_arguments_per_list, + field_name="max_arguments_per_list", + ) + validate_positive_int( + self.max_expression_depth, + field_name="max_expression_depth", + ) + validate_positive_int( + self.max_node_count, + field_name="max_node_count", + ) DEFAULT_PARSE_LIMITS = ParseLimits() diff --git a/src/pyrsql/parsing/operators.py b/src/pyrsql/parsing/operators.py index fb8b26c..c82eaed 100644 --- a/src/pyrsql/parsing/operators.py +++ b/src/pyrsql/parsing/operators.py @@ -11,6 +11,36 @@ from collections.abc import Mapping +def _validate_non_empty_string(value: str, *, field_name: str) -> None: + """Validates one required non-empty string field. + + Raises: + TypeError: If the field is not a string. + ValueError: If the field is empty or padded with outer whitespace. + """ + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string.") + if not value: + raise ValueError(f"{field_name} cannot be empty.") + if value != value.strip(): + raise ValueError( + f"{field_name} must not contain outer whitespace.", + ) + + +def _validate_non_negative_int(value: int, *, field_name: str) -> None: + """Validates one non-negative integer field. + + Raises: + TypeError: If the field is not an integer. + ValueError: If the field is negative. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{field_name} must be an integer.") + if value < 0: + raise ValueError(f"{field_name} cannot be negative.") + + class ComparisonOperator(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Represents a supported comparison operator.""" @@ -23,18 +53,33 @@ def __post_init__(self) -> None: """Validates operator invariants. Raises: + TypeError: If the operator definition uses invalid runtime types. ValueError: If the operator definition is invalid. """ - if not self.name: - raise ValueError("Operator name cannot be empty.") + _validate_non_empty_string(self.name, field_name="name") + if isinstance(self.spellings, str) or not isinstance( + self.spellings, + tuple, + ): + raise TypeError("spellings must be a tuple of strings.") if not self.spellings: raise ValueError("Operator must define at least one spelling.") - if any(not spelling for spelling in self.spellings): - raise ValueError("Operator spellings cannot be empty.") + for spelling in self.spellings: + _validate_non_empty_string( + spelling, + field_name="operator spelling", + ) if len(set(self.spellings)) != len(self.spellings): raise ValueError("Operator spellings must be unique.") - if self.minimum_arguments < 0: - raise ValueError("minimum_arguments cannot be negative.") + _validate_non_negative_int( + self.minimum_arguments, + field_name="minimum_arguments", + ) + if self.maximum_arguments is not None: + _validate_non_negative_int( + self.maximum_arguments, + field_name="maximum_arguments", + ) if ( self.maximum_arguments is not None and self.maximum_arguments < self.minimum_arguments @@ -48,11 +93,10 @@ class OperatorRegistry(msgspec.Struct, frozen=True, gc=False): """Immutable registry of supported comparison operators.""" operators: tuple[ComparisonOperator, ...] = () - operators_by_spelling: Mapping[str, ComparisonOperator] = MappingProxyType( + _operators_by_spelling: Mapping[str, ComparisonOperator] = MappingProxyType( {} ) - operator_spellings: tuple[str, ...] = () - operator_spellings_by_prefix: Mapping[str, tuple[str, ...]] = ( + _operator_spellings_by_prefix: Mapping[str, tuple[str, ...]] = ( MappingProxyType({}) ) @@ -60,11 +104,24 @@ def __post_init__(self) -> None: """Builds lookup structures and validates uniqueness. Raises: + TypeError: If the registered operators do not match the runtime + contract. ValueError: If no operators are registered or spellings collide. """ + if isinstance(self.operators, str) or not isinstance( + self.operators, + tuple, + ): + raise TypeError("operators must be a tuple of ComparisonOperator.") if not self.operators: raise ValueError("Operator registry must contain operators.") + for operator in self.operators: + if not isinstance(operator, ComparisonOperator): + raise TypeError( + "operators must contain only ComparisonOperator instances.", + ) operators_by_spelling: dict[str, ComparisonOperator] = {} + spellings_by_prefix: dict[str, list[str]] = {} for operator in self.operators: for spelling in operator.spellings: if spelling in operators_by_spelling: @@ -72,28 +129,15 @@ def __post_init__(self) -> None: f"Duplicate operator spelling registered: {spelling!r}.", # noqa: E501 ) operators_by_spelling[spelling] = operator - spellings_by_prefix: dict[str, list[str]] = {} - for spelling in operators_by_spelling: - spellings_by_prefix.setdefault(spelling[0], []).append(spelling) + spellings_by_prefix.setdefault(spelling[0], []).append(spelling) msgspec.structs.force_setattr( self, - "operators_by_spelling", + "_operators_by_spelling", MappingProxyType(operators_by_spelling), ) msgspec.structs.force_setattr( self, - "operator_spellings", - tuple( - sorted( - operators_by_spelling, - key=len, - reverse=True, - ), - ), - ) - msgspec.structs.force_setattr( - self, - "operator_spellings_by_prefix", + "_operator_spellings_by_prefix", MappingProxyType( { prefix: tuple(sorted(spellings, key=len, reverse=True)) @@ -108,7 +152,7 @@ def get(self, spelling: str) -> ComparisonOperator: Returns: The registered comparison operator. """ - return self.operators_by_spelling[spelling] + return self._operators_by_spelling[spelling] def match_candidates(self, prefix: str) -> tuple[str, ...]: """Returns operator spellings that can start with the prefix. @@ -116,7 +160,7 @@ def match_candidates(self, prefix: str) -> tuple[str, ...]: Returns: Candidate operator spellings for the prefix. """ - return self.operator_spellings_by_prefix.get(prefix, ()) + return self._operator_spellings_by_prefix.get(prefix, ()) EQUAL = ComparisonOperator( @@ -245,5 +289,3 @@ def match_candidates(self, prefix: str) -> tuple[str, ...]: DEFAULT_OPERATOR_REGISTRY = OperatorRegistry( operators=SUPPORTED_COMPARISON_OPERATORS, ) -OPERATOR_SPELLINGS = DEFAULT_OPERATOR_REGISTRY.operator_spellings -OPERATORS_BY_SPELLING = DEFAULT_OPERATOR_REGISTRY.operators_by_spelling diff --git a/src/pyrsql/parsing/parser.py b/src/pyrsql/parsing/parser.py index 5a084ce..2ca23a9 100644 --- a/src/pyrsql/parsing/parser.py +++ b/src/pyrsql/parsing/parser.py @@ -40,6 +40,9 @@ TokenKind.SEMICOLON, TokenKind.COMMA, ) +_VALUE_TOKEN_KINDS = frozenset( + (TokenKind.UNQUOTED_TEXT, TokenKind.QUOTED_TEXT), +) class Parser: @@ -57,17 +60,54 @@ def __init__( operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY, ) -> None: """Initializes the parser with raw source text and limits.""" - self._limits = limits or DEFAULT_PARSE_LIMITS - self._operator_registry = operator_registry + self._limits = self._normalize_limits(limits) + self._operator_registry = self._normalize_operator_registry( + operator_registry, + ) self._tokens = Lexer( source, limits=self._limits, - operator_registry=operator_registry, + operator_registry=self._operator_registry, ).tokenize() - self._selector_parser = DEFAULT_SELECTOR_PARSER + self._token_count = len(self._tokens) self._index = 0 self._node_count = 0 + @staticmethod + def _normalize_limits(limits: ParseLimits | None) -> ParseLimits: + """Normalizes optional parser limits. + + Returns: + The provided limits, or the shared defaults. + + Raises: + TypeError: If ``limits`` is not a ``ParseLimits`` instance. + """ + if limits is None: + return DEFAULT_PARSE_LIMITS + if not isinstance(limits, type(DEFAULT_PARSE_LIMITS)): + raise TypeError("Parser limits must be a ParseLimits instance.") + return limits + + @staticmethod + def _normalize_operator_registry( + operator_registry: OperatorRegistry, + ) -> OperatorRegistry: + """Normalizes the operator registry dependency. + + Returns: + The validated operator registry instance. + + Raises: + TypeError: If the registry is not an ``OperatorRegistry``. + """ + if not isinstance(operator_registry, type(DEFAULT_OPERATOR_REGISTRY)): + raise TypeError( + "Parser operator_registry must be an " + "OperatorRegistry instance.", + ) + return operator_registry + @property def limits(self) -> ParseLimits: """Returns the configured parser safety limits. @@ -94,9 +134,9 @@ def _parse_or_expression(self, depth: int) -> Expression: The parsed expression subtree. """ self._enforce_depth(depth) - nodes = [self._parse_and_expression(depth + 1)] + nodes = [self._parse_and_expression(depth)] while self._match(TokenKind.OR, TokenKind.COMMA): - nodes.append(self._parse_and_expression(depth + 1)) + nodes.append(self._parse_and_expression(depth)) if len(nodes) == 1: return nodes[0] return self._make_logical_node(LogicalOperator.OR, nodes) @@ -107,10 +147,9 @@ def _parse_and_expression(self, depth: int) -> Expression: Returns: The parsed expression subtree. """ - self._enforce_depth(depth) - nodes = [self._parse_primary_expression(depth + 1)] + nodes = [self._parse_primary_expression(depth)] while self._match(TokenKind.AND, TokenKind.SEMICOLON): - nodes.append(self._parse_primary_expression(depth + 1)) + nodes.append(self._parse_primary_expression(depth)) if len(nodes) == 1: return nodes[0] return self._make_logical_node(LogicalOperator.AND, nodes) @@ -120,13 +159,20 @@ def _parse_primary_expression(self, depth: int) -> Expression: Returns: The parsed primary expression. + + Raises: + ParseError: If a grouped expression is malformed. """ - self._enforce_depth(depth) if self._current().kind is TokenKind.LPAREN: opening = self._expect( TokenKind.LPAREN, message="Expected '(' to start grouped expression", ) + if self._current().kind is TokenKind.RPAREN: + raise ParseError( + message="Grouped expression cannot be empty", + span=self._current().span, + ) expression = self._parse_or_expression(depth + 1) closing = self._expect( TokenKind.RPAREN, @@ -151,7 +197,7 @@ def _parse_comparison(self) -> ComparisonNode: message="Expected a selector before the comparison operator", ) try: - parsed_selector = self._selector_parser.parse( + parsed_selector = DEFAULT_SELECTOR_PARSER.parse( selector.lexeme, max_length=self._limits.max_selector_length, context="Comparison selector", @@ -318,7 +364,7 @@ def _expect_value(self, message: str) -> Token: ParseError: If the current token is not a value token. """ current = self._current() - if current.kind not in {TokenKind.UNQUOTED_TEXT, TokenKind.QUOTED_TEXT}: + if current.kind not in _VALUE_TOKEN_KINDS: raise ParseError(message=message, span=current.span) self._advance() return current @@ -390,5 +436,5 @@ def _current(self) -> Token: def _advance(self) -> None: """Advances to the next token when possible.""" - if self._index < len(self._tokens) - 1: + if self._index < self._token_count - 1: self._index += 1 diff --git a/src/pyrsql/parsing/source.py b/src/pyrsql/parsing/source.py index 9197300..5553132 100644 --- a/src/pyrsql/parsing/source.py +++ b/src/pyrsql/parsing/source.py @@ -12,19 +12,6 @@ class SourcePosition(msgspec.Struct, frozen=True, gc=False, kw_only=True): line: int column: int - def __post_init__(self) -> None: - """Validates source position invariants. - - Raises: - ValueError: If the position indices are invalid. - """ - if self.index < 0: - raise ValueError("Source position index cannot be negative.") - if self.line <= 0: - raise ValueError("Source position line must be greater than 0.") - if self.column <= 0: - raise ValueError("Source position column must be greater than 0.") - class SourceSpan(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Represents a half-open span in the input source.""" @@ -32,15 +19,6 @@ class SourceSpan(msgspec.Struct, frozen=True, gc=False, kw_only=True): start: SourcePosition end: SourcePosition - def __post_init__(self) -> None: - """Validates span ordering invariants. - - Raises: - ValueError: If the span end precedes the start. - """ - if self.end.index < self.start.index: - raise ValueError("Source span end cannot precede start.") - @classmethod def cover(cls, start: SourceSpan, end: SourceSpan) -> SourceSpan: """Builds a span that covers two existing spans. @@ -56,15 +34,6 @@ class SourceText(msgspec.Struct, frozen=True, gc=False, kw_only=True): text: str - def __post_init__(self) -> None: - """Validates the source text. - - Raises: - TypeError: If the source text is not a string. - """ - if not isinstance(self.text, str): - raise TypeError("Source text must be a string.") - @property def length(self) -> int: """Returns the source length. diff --git a/src/pyrsql/selector/__init__.py b/src/pyrsql/selector/__init__.py index 1120081..275800d 100644 --- a/src/pyrsql/selector/__init__.py +++ b/src/pyrsql/selector/__init__.py @@ -7,14 +7,9 @@ SelectorLiteral, SelectorNode, ) -from pyrsql.selector.parser import ( - DEFAULT_SELECTOR_PARSER, - SelectorParseError, - SelectorParser, -) +from pyrsql.selector.parser import SelectorParseError, SelectorParser __all__ = [ - "DEFAULT_SELECTOR_PARSER", "FieldSelector", "FunctionSelector", "LiteralSelector", diff --git a/src/pyrsql/selector/ast.py b/src/pyrsql/selector/ast.py index bd30c14..1133631 100644 --- a/src/pyrsql/selector/ast.py +++ b/src/pyrsql/selector/ast.py @@ -26,29 +26,6 @@ class FieldSelector(SelectorNode, frozen=True, gc=False, kw_only=True): raw_path: str segments: tuple[str, ...] - def __post_init__(self) -> None: - """Validates field selector invariants. - - Raises: - ValueError: If the field selector path is invalid. - """ - if not self.raw_path: - raise ValueError("Field selector path cannot be empty.") - if self.raw_path != self.raw_path.strip(): - raise ValueError( - "Field selector path cannot contain outer whitespace.", - ) - if not self.segments: - raise ValueError( - "Field selector must contain at least one segment.", - ) - if any(not segment for segment in self.segments): - raise ValueError( - "Field selector cannot contain empty path segments.", - ) - if tuple(self.raw_path.split(".")) != self.segments: - raise ValueError("Field selector segments must match the raw path.") - class LiteralSelector(SelectorNode, frozen=True, gc=False, kw_only=True): """Static literal selector.""" @@ -62,23 +39,6 @@ class FunctionSelector(SelectorNode, frozen=True, gc=False, kw_only=True): function_name: str arguments: tuple[SelectorNode, ...] - def __post_init__(self) -> None: - """Validates function selector invariants. - - Raises: - ValueError: If the function selector is invalid. - """ - if not self.function_name: - raise ValueError("Function selector name cannot be empty.") - if self.function_name != self.function_name.strip(): - raise ValueError( - "Function selector name cannot contain outer whitespace.", - ) - if not self.arguments: - raise ValueError( - "Function selector must contain at least one argument.", - ) - def walk(self) -> Iterator[SelectorNode]: """Yields this selector and all nested selectors depth-first.""" yield self diff --git a/src/pyrsql/selector/parser.py b/src/pyrsql/selector/parser.py index 59a235e..1179343 100644 --- a/src/pyrsql/selector/parser.py +++ b/src/pyrsql/selector/parser.py @@ -5,11 +5,7 @@ import re from typing import TYPE_CHECKING -from pyrsql.selector.ast import ( - FieldSelector, - FunctionSelector, - LiteralSelector, -) +from pyrsql.selector.ast import FieldSelector, FunctionSelector, LiteralSelector if TYPE_CHECKING: from pyrsql.selector.ast import ( @@ -41,19 +37,13 @@ def parse( The parsed selector node. Raises: - TypeError: If the selector text is not a string. SelectorParseError: If the selector text is invalid. """ - if not isinstance(raw_selector, str): - raise TypeError("Selector text must be a string.") - normalized_selector = raw_selector.strip() - if not normalized_selector: - raise SelectorParseError(f"{context} cannot be empty.") - if len(normalized_selector) > max_length: - raise SelectorParseError( - f"{context} exceeds the maximum supported length of " - f"{max_length}.", - ) + normalized_selector = self._normalize_selector_text( + raw_selector, + max_length=max_length, + context=context, + ) if normalized_selector.startswith("@"): return self._parse_function_selector( normalized_selector, @@ -64,9 +54,15 @@ def parse( return LiteralSelector( value=self._parse_literal_value(normalized_selector[1:]), ) + segments = tuple(normalized_selector.split(".")) + if any(not segment for segment in segments): + raise SelectorParseError( + f"{context} has invalid field selector " + f"{normalized_selector!r}: empty path segments.", + ) return FieldSelector( raw_path=normalized_selector, - segments=tuple(normalized_selector.split(".")), + segments=segments, ) @staticmethod @@ -77,8 +73,17 @@ def split_top_level(text: str, *, delimiter: str) -> tuple[str, ...]: The top-level split fragments. Raises: + TypeError: If the text or delimiter do not match the runtime + contract. + ValueError: If the delimiter is not a single character. SelectorParseError: If the brackets are unbalanced. """ + if not isinstance(text, str): + raise TypeError("Selector fragment text must be a string.") + if not isinstance(delimiter, str): + raise TypeError("Selector delimiter must be a string.") + if len(delimiter) != 1: + raise ValueError("Selector delimiter must be a single character.") parts: list[str] = [] start_index = 0 depth = 0 @@ -105,6 +110,89 @@ def split_top_level(text: str, *, delimiter: str) -> tuple[str, ...]: parts.append(final_part) return tuple(parts) + @staticmethod + def _split_required_top_level( + text: str, + *, + delimiter: str, + context: str, + ) -> tuple[str, ...]: + """Splits top-level fragments while rejecting empty entries. + + Returns: + The normalized non-empty fragments. + + Raises: + SelectorParseError: If brackets are unbalanced or a fragment is + empty. + """ + parts: list[str] = [] + start_index = 0 + depth = 0 + for index, character in enumerate(text): + if character == "[": + depth += 1 + elif character == "]": + depth -= 1 + if depth < 0: + raise SelectorParseError( + f"Selector fragment {text!r} has unbalanced brackets.", + ) + elif character == delimiter and depth == 0: + part = text[start_index:index].strip() + if not part: + raise SelectorParseError(context) + parts.append(part) + start_index = index + 1 + if depth != 0: + raise SelectorParseError( + f"Selector fragment {text!r} has unbalanced brackets.", + ) + final_part = text[start_index:].strip() + if not final_part: + raise SelectorParseError(context) + parts.append(final_part) + return tuple(parts) + + @staticmethod + def _normalize_selector_text( + raw_selector: str, + *, + max_length: int, + context: str, + ) -> str: + """Validates and normalizes selector parser inputs. + + Returns: + The stripped selector text. + + Raises: + TypeError: If the selector text, maximum length, or context use + invalid runtime types. + ValueError: If ``max_length`` is not greater than zero. + SelectorParseError: If the selector text is empty or too long. + """ + if not isinstance(raw_selector, str): + raise TypeError("Selector text must be a string.") + if not isinstance(context, str): + raise TypeError("Selector context must be a string.") + if not context: + raise ValueError("Selector context cannot be empty.") + if isinstance(max_length, bool) or not isinstance(max_length, int): + raise TypeError("Selector max_length must be an integer.") + if max_length <= 0: + raise ValueError("Selector max_length must be greater than 0.") + + normalized_selector = raw_selector.strip() + if not normalized_selector: + raise SelectorParseError(f"{context} cannot be empty.") + if len(normalized_selector) > max_length: + raise SelectorParseError( + f"{context} exceeds the maximum supported length of " + f"{max_length}.", + ) + return normalized_selector + def _parse_function_selector( self, raw_selector: str, @@ -134,12 +222,24 @@ def _parse_function_selector( if not function_name: raise SelectorParseError(f"{context} has empty function name.") arguments_text = raw_selector[argument_start + 1 : argument_end] - argument_fragments = self.split_top_level(arguments_text, delimiter="|") - if not argument_fragments: - raise SelectorParseError( - f"{context} function {function_name!r} must have at least one " - "argument.", + try: + argument_fragments = self._split_required_top_level( + arguments_text, + delimiter="|", + context=( + f"{context} function {function_name!r} " + "cannot contain empty arguments." + ), ) + except SelectorParseError as error: + if str(error).startswith("Selector fragment "): + raise + if not arguments_text.strip(): + raise SelectorParseError( + f"{context} function {function_name!r} " + "must have at least one argument.", + ) from None + raise return FunctionSelector( function_name=function_name, arguments=tuple( diff --git a/src/pyrsql/semantic/binder.py b/src/pyrsql/semantic/binder.py index 5afb705..a7beb26 100644 --- a/src/pyrsql/semantic/binder.py +++ b/src/pyrsql/semantic/binder.py @@ -2,8 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING +from pyrsql._policy import ( + MappedFieldBindingOptions, + enforce_field_access_policy, + enforce_function_access_policy, +) from pyrsql.ir.query import ( BoundArgument, BoundComparison, @@ -26,8 +31,6 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping - from pyrsql.ir.query import ( BoundSelectorNode, ) @@ -38,40 +41,10 @@ ) -class ProcedurePolicyProtocol(Protocol): - """Structural contract for procedure access policies.""" - - def is_whitelisted(self, function_name: str) -> bool: - """Returns whether the function is allowed by the whitelist.""" - - def is_blacklisted(self, function_name: str) -> bool: - """Returns whether the function is blocked by the blacklist.""" - - -class SemanticBindingOptions(Protocol): - """Structural options contract required by the semantic binder.""" - - @property - def field_mapping(self) -> Mapping[str, str]: - """Field mapping available to the binder.""" - - @property - def field_whitelist(self) -> frozenset[str] | set[str]: - """Field whitelist available to the binder.""" - - @property - def field_blacklist(self) -> frozenset[str] | set[str]: - """Field blacklist available to the binder.""" - - @property - def procedure_policy(self) -> ProcedurePolicyProtocol: - """Procedure access policy available to the binder.""" - - class SemanticBinder: """Binds parsed query AST nodes into logical IR.""" - def __init__(self, options: SemanticBindingOptions) -> None: + def __init__(self, options: MappedFieldBindingOptions) -> None: """Initializes the binder with semantic binding options.""" self._field_whitelist = options.field_whitelist self._field_blacklist = options.field_blacklist @@ -90,7 +63,9 @@ def bind(self, expression: Expression) -> BoundComparison | BoundLogical: if isinstance(expression, ComparisonNode): return self._bind_comparison(expression) if not isinstance(expression, LogicalNode): - raise TypeError("Expected LogicalNode") + raise TypeError( + "Expected ComparisonNode or LogicalNode expression.", + ) return BoundLogical( span=expression.span, operator=expression.operator, @@ -133,19 +108,33 @@ def _bind_selector( Returns: The bound selector node. + + Raises: + TypeError: If the selector is not a supported selector node. """ - return _bind_selector( - selector, - field_mapping=self._field_mapping, - validate_field=lambda field_path: self._enforce_field_access_policy( - field_path, - span, - ), - validate_function=( - lambda function_name: self._enforce_function_access_policy( - function_name, - span=span, - ) + if isinstance(selector, FieldSelector): + field_path = self._field_mapping.get( + selector.raw_path, + selector.raw_path, + ) + self._enforce_field_access_policy(field_path, span) + return BoundField( + raw_path=selector.raw_path, + field_path=field_path, + segments=tuple(field_path.split(".")), + ) + if isinstance(selector, LiteralSelector): + return BoundLiteral(value=selector.value) + if not isinstance(selector, FunctionSelector): + raise TypeError( + "Expected FieldSelector, LiteralSelector, or FunctionSelector.", + ) + self._enforce_function_access_policy(selector.function_name, span=span) + return BoundFunction( + function_name=selector.function_name, + arguments=tuple( + self._bind_selector(argument, span=span) + for argument in selector.arguments ), ) @@ -154,22 +143,18 @@ def _enforce_field_access_policy( field_path: str, span: SourceSpan, ) -> None: - """Validates whitelist and blacklist rules. - - Raises: - FieldNotWhitelistedError: If the field is not allowed. - FieldBlacklistedError: If the field is blocked. - """ - if self._field_whitelist and field_path not in self._field_whitelist: - raise FieldNotWhitelistedError( - message=f"Field {field_path!r} is not allowed", - span=span, - ) - if field_path in self._field_blacklist: - raise FieldBlacklistedError( - message=f"Field {field_path!r} is blocked", - span=span, - ) + """Validates whitelist and blacklist rules.""" + enforce_field_access_policy( + field_path, + field_whitelist=self._field_whitelist, + field_blacklist=self._field_blacklist, + not_whitelisted_error_factory=lambda message: ( + FieldNotWhitelistedError(message=message, span=span) + ), + blacklisted_error_factory=lambda message: FieldBlacklistedError( + message=message, span=span + ), + ) def _enforce_function_access_policy( self, @@ -177,61 +162,14 @@ def _enforce_function_access_policy( *, span: SourceSpan, ) -> None: - """Validates whitelist and blacklist rules for functions. - - Raises: - FunctionNotWhitelistedError: If the function is not allowed. - FunctionBlacklistedError: If the function is blocked. - """ - if not self._procedure_policy.is_whitelisted(function_name): - raise FunctionNotWhitelistedError( - message=f"Function {function_name!r} is not whitelisted", - span=span, - ) - if self._procedure_policy.is_blacklisted(function_name): - raise FunctionBlacklistedError( - message=f"Function {function_name!r} is blacklisted", - span=span, - ) - - -def _bind_selector( - selector: SelectorNode, - *, - field_mapping: Mapping[str, str], - validate_field: Callable[[str], None], - validate_function: Callable[[str], None], -) -> BoundSelectorNode: - """Binds one parsed selector recursively. - - Returns: - The bound selector node. - - Raises: - TypeError: If the selector is not a supported selector node. - """ - if isinstance(selector, FieldSelector): - field_path = field_mapping.get(selector.raw_path, selector.raw_path) - validate_field(field_path) - return BoundField( - raw_path=selector.raw_path, - field_path=field_path, - segments=tuple(field_path.split(".")), + """Validates whitelist and blacklist rules for functions.""" + enforce_function_access_policy( + function_name, + procedure_policy=self._procedure_policy, + not_whitelisted_error_factory=lambda message: ( + FunctionNotWhitelistedError(message=message, span=span) + ), + blacklisted_error_factory=lambda message: FunctionBlacklistedError( + message=message, span=span + ), ) - if isinstance(selector, LiteralSelector): - return BoundLiteral(value=selector.value) - if not isinstance(selector, FunctionSelector): - raise TypeError("Expected FunctionSelector") - validate_function(selector.function_name) - return BoundFunction( - function_name=selector.function_name, - arguments=tuple( - _bind_selector( - argument, - field_mapping=field_mapping, - validate_field=validate_field, - validate_function=validate_function, - ) - for argument in selector.arguments - ), - ) diff --git a/src/pyrsql/semantic/errors.py b/src/pyrsql/semantic/errors.py index b43b7ae..7543f0b 100644 --- a/src/pyrsql/semantic/errors.py +++ b/src/pyrsql/semantic/errors.py @@ -38,7 +38,10 @@ def __str__(self) -> str: Returns: The formatted semantic error string. """ - return str(self.diagnostic) + return ( + f"[{self.code}] {self.message} at index {self.span.start.index} " + f"(line {self.span.start.line}, column {self.span.start.column})" + ) @dataclass(frozen=True, slots=True) diff --git a/src/pyrsql/sorting/ast.py b/src/pyrsql/sorting/ast.py index 0fd5405..1ba45c4 100644 --- a/src/pyrsql/sorting/ast.py +++ b/src/pyrsql/sorting/ast.py @@ -3,10 +3,12 @@ from __future__ import annotations from enum import Enum +from typing import TYPE_CHECKING import msgspec -from pyrsql.selector.ast import SelectorNode +if TYPE_CHECKING: + from pyrsql.selector.ast import SelectorNode class SortDirection(Enum): @@ -24,6 +26,7 @@ def from_raw(cls, raw_direction: str) -> SortDirection | None: Returns: The matching direction, or None when the token is unsupported. + """ match raw_direction.lower(): case "asc": @@ -46,16 +49,3 @@ class SortField(msgspec.Struct, frozen=True, gc=False, kw_only=True): selector: SelectorNode direction: SortDirection ignore_case: bool - - def __post_init__(self) -> None: - """Validates sort field invariants. - - Raises: - TypeError: If any sort field component has the wrong type. - """ - if not isinstance(self.selector, SelectorNode): - raise TypeError("Sort field selector must be a SelectorNode.") - if not isinstance(self.direction, SortDirection): - raise TypeError("Sort field direction must be a SortDirection.") - if not isinstance(self.ignore_case, bool): - raise TypeError("Sort field ignore_case must be a bool.") diff --git a/src/pyrsql/sorting/binder.py b/src/pyrsql/sorting/binder.py index 95c3f87..2315a12 100644 --- a/src/pyrsql/sorting/binder.py +++ b/src/pyrsql/sorting/binder.py @@ -2,8 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING +from pyrsql._policy import ( + MappedFieldBindingOptions, + enforce_field_access_policy, + enforce_function_access_policy, +) from pyrsql.ir.query import ( BoundField, BoundFunction, @@ -23,8 +28,6 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping - from pyrsql.ir.query import ( BoundSelectorNode, ) @@ -34,40 +37,10 @@ from pyrsql.sorting.ast import SortField -class ProcedurePolicyProtocol(Protocol): - """Structural contract for procedure access policies.""" - - def is_whitelisted(self, function_name: str) -> bool: - """Returns whether the function is allowed by the whitelist.""" - - def is_blacklisted(self, function_name: str) -> bool: - """Returns whether the function is blocked by the blacklist.""" - - -class SortBindingOptions(Protocol): - """Structural options contract required by the sort binder.""" - - @property - def field_mapping(self) -> Mapping[str, str]: - """Field mapping available to the binder.""" - - @property - def field_whitelist(self) -> frozenset[str] | set[str]: - """Field whitelist available to the binder.""" - - @property - def field_blacklist(self) -> frozenset[str] | set[str]: - """Field blacklist available to the binder.""" - - @property - def procedure_policy(self) -> ProcedurePolicyProtocol: - """Procedure access policy available to the binder.""" - - class SortBinder: """Binds parsed sort fields into logical sort IR.""" - def __init__(self, options: SortBindingOptions) -> None: + def __init__(self, options: MappedFieldBindingOptions) -> None: """Initializes the binder with sort binding options.""" self._field_whitelist = options.field_whitelist self._field_blacklist = options.field_blacklist @@ -101,84 +74,58 @@ def _bind_selector(self, selector: SelectorNode) -> BoundSelectorNode: Returns: The bound selector node. - """ - return _bind_selector( - selector, - field_mapping=self._field_mapping, - validate_field=self._enforce_field_access_policy, - validate_function=self._enforce_function_access_policy, - ) - - def _enforce_field_access_policy(self, field_path: str) -> None: - """Validates whitelist and blacklist rules. Raises: - SortFieldNotWhitelistedError: If the field is not allowed. - SortFieldBlacklistedError: If the field is blocked. + TypeError: If the selector is not a supported selector node. """ - if self._field_whitelist and field_path not in self._field_whitelist: - raise SortFieldNotWhitelistedError( - message=f"Field {field_path!r} is not allowed", + if isinstance(selector, FieldSelector): + field_path = self._field_mapping.get( + selector.raw_path, + selector.raw_path, ) - if field_path in self._field_blacklist: - raise SortFieldBlacklistedError( - message=f"Field {field_path!r} is blocked", - ) - - def _enforce_function_access_policy(self, function_name: str) -> None: - """Validates whitelist and blacklist rules for functions. - - Raises: - SortFunctionNotWhitelistedError: If the function is not allowed. - SortFunctionBlacklistedError: If the function is blocked. - """ - if not self._procedure_policy.is_whitelisted(function_name): - raise SortFunctionNotWhitelistedError( - message=f"Function {function_name!r} is not whitelisted", + self._enforce_field_access_policy(field_path) + return BoundField( + raw_path=selector.raw_path, + field_path=field_path, + segments=tuple(field_path.split(".")), ) - if self._procedure_policy.is_blacklisted(function_name): - raise SortFunctionBlacklistedError( - message=f"Function {function_name!r} is blacklisted", + if isinstance(selector, LiteralSelector): + return BoundLiteral(value=selector.value) + if not isinstance(selector, FunctionSelector): + raise TypeError( + "Expected FieldSelector, LiteralSelector, or FunctionSelector.", ) + self._enforce_function_access_policy(selector.function_name) + return BoundFunction( + function_name=selector.function_name, + arguments=tuple( + self._bind_selector(argument) for argument in selector.arguments + ), + ) + def _enforce_field_access_policy(self, field_path: str) -> None: + """Validates whitelist and blacklist rules.""" + enforce_field_access_policy( + field_path, + field_whitelist=self._field_whitelist, + field_blacklist=self._field_blacklist, + not_whitelisted_error_factory=lambda message: ( + SortFieldNotWhitelistedError(message=message) + ), + blacklisted_error_factory=lambda message: SortFieldBlacklistedError( + message=message + ), + ) -def _bind_selector( - selector: SelectorNode, - *, - field_mapping: Mapping[str, str], - validate_field: Callable[[str], None], - validate_function: Callable[[str], None], -) -> BoundSelectorNode: - """Binds one parsed selector recursively. - - Returns: - The bound selector node. - - Raises: - TypeError: If the selector is not a supported selector node. - """ - if isinstance(selector, FieldSelector): - field_path = field_mapping.get(selector.raw_path, selector.raw_path) - validate_field(field_path) - return BoundField( - raw_path=selector.raw_path, - field_path=field_path, - segments=tuple(field_path.split(".")), + def _enforce_function_access_policy(self, function_name: str) -> None: + """Validates whitelist and blacklist rules for functions.""" + enforce_function_access_policy( + function_name, + procedure_policy=self._procedure_policy, + not_whitelisted_error_factory=lambda message: ( + SortFunctionNotWhitelistedError(message=message) + ), + blacklisted_error_factory=lambda message: ( + SortFunctionBlacklistedError(message=message) + ), ) - if isinstance(selector, LiteralSelector): - return BoundLiteral(value=selector.value) - if not isinstance(selector, FunctionSelector): - raise TypeError("Expected FunctionSelector") - validate_function(selector.function_name) - return BoundFunction( - function_name=selector.function_name, - arguments=tuple( - _bind_selector( - argument, - field_mapping=field_mapping, - validate_field=validate_field, - validate_function=validate_function, - ) - for argument in selector.arguments - ), - ) diff --git a/src/pyrsql/sorting/diagnostics.py b/src/pyrsql/sorting/diagnostics.py index ee2b055..adc6294 100644 --- a/src/pyrsql/sorting/diagnostics.py +++ b/src/pyrsql/sorting/diagnostics.py @@ -1,5 +1,7 @@ """Structured sorting diagnostics.""" +from __future__ import annotations + import msgspec diff --git a/src/pyrsql/sorting/errors.py b/src/pyrsql/sorting/errors.py index 3bf0c25..ca78880 100644 --- a/src/pyrsql/sorting/errors.py +++ b/src/pyrsql/sorting/errors.py @@ -1,5 +1,7 @@ """Errors raised while parsing or binding sort expressions.""" +from __future__ import annotations + from dataclasses import dataclass from typing import ClassVar @@ -28,7 +30,7 @@ def __str__(self) -> str: Returns: The formatted sort error string. """ - return str(self.diagnostic) + return f"[{self.code}] {self.message}" @dataclass(frozen=True, slots=True) diff --git a/src/pyrsql/sorting/limits.py b/src/pyrsql/sorting/limits.py index 454d62b..afa0a57 100644 --- a/src/pyrsql/sorting/limits.py +++ b/src/pyrsql/sorting/limits.py @@ -1,7 +1,11 @@ """Security and complexity limits for sort parsing.""" +from __future__ import annotations + import msgspec +from pyrsql._validation import validate_positive_int + class SortLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): """Configurable limits for sort parsing.""" @@ -11,17 +15,19 @@ class SortLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): max_field_path_length: int = 256 def __post_init__(self) -> None: - """Validates sort parser limit invariants. - - Raises: - ValueError: If any sort limit is non-positive. - """ - if self.max_sort_length <= 0: - raise ValueError("max_sort_length must be greater than 0.") - if self.max_fields <= 0: - raise ValueError("max_fields must be greater than 0.") - if self.max_field_path_length <= 0: - raise ValueError("max_field_path_length must be greater than 0.") + """Validates sort parser limit invariants.""" + validate_positive_int( + self.max_sort_length, + field_name="max_sort_length", + ) + validate_positive_int( + self.max_fields, + field_name="max_fields", + ) + validate_positive_int( + self.max_field_path_length, + field_name="max_field_path_length", + ) DEFAULT_SORT_LIMITS = SortLimits() diff --git a/src/pyrsql/sorting/parser.py b/src/pyrsql/sorting/parser.py index 1ae48c5..df6817a 100644 --- a/src/pyrsql/sorting/parser.py +++ b/src/pyrsql/sorting/parser.py @@ -7,11 +7,10 @@ from pyrsql.selector.parser import DEFAULT_SELECTOR_PARSER, SelectorParseError from pyrsql.sorting.ast import SortDirection, SortField from pyrsql.sorting.errors import SortParseError -from pyrsql.sorting.limits import DEFAULT_SORT_LIMITS +from pyrsql.sorting.limits import DEFAULT_SORT_LIMITS, SortLimits if TYPE_CHECKING: from pyrsql.selector.ast import SelectorNode - from pyrsql.sorting.limits import SortLimits _EMPTY_FIELDS: Final[tuple[SortField, ...]] = () _MAX_SORT_PARTS: Final = 3 @@ -28,9 +27,40 @@ def __init__( limits: SortLimits | None = None, ) -> None: """Initializes the parser with raw sort text and limits.""" - self._source = (source or "").strip() - self._limits = limits or DEFAULT_SORT_LIMITS - self._selector_parser = DEFAULT_SELECTOR_PARSER + self._source = self._normalize_source(source) + self._limits = self._normalize_limits(limits) + + @staticmethod + def _normalize_source(source: str | None) -> str: + """Validates and normalizes raw sort source text. + + Returns: + The stripped sort source text, or an empty string for ``None``. + + Raises: + TypeError: If the provided source is not a string or ``None``. + """ + if source is None: + return "" + if not isinstance(source, str): + raise TypeError("Sort source must be a string or None.") + return source.strip() + + @staticmethod + def _normalize_limits(limits: SortLimits | None) -> SortLimits: + """Validates and normalizes sort parser limits. + + Returns: + The provided limits, or the shared defaults when omitted. + + Raises: + TypeError: If the provided limits are not a ``SortLimits`` instance. + """ + if limits is None: + return DEFAULT_SORT_LIMITS + if not isinstance(limits, SortLimits): + raise TypeError("Sort limits must be a SortLimits instance.") + return limits def parse(self) -> tuple[SortField, ...]: """Parses the configured sort expression. @@ -83,17 +113,14 @@ def _parse_clause( if not clause: return None try: - parts = list( - self._selector_parser.split_top_level( - clause, - delimiter=",", - ), + parts = self._split_clause_parts( + clause, + clause_index=clause_index, ) except SelectorParseError as error: raise SortParseError(str(error)) from error if not parts: return None - parts = [part.strip() for part in parts] if len(parts) > _MAX_SORT_PARTS: raise SortParseError( "Sort clause " @@ -125,6 +152,33 @@ def _parse_clause( ignore_case=ignore_case, ) + @staticmethod + def _split_clause_parts( + clause: str, + *, + clause_index: int, + ) -> tuple[str, ...]: + """Splits one sort clause while rejecting empty comma parts. + + Returns: + The normalized comma-separated parts. + + Raises: + SortParseError: If the clause contains an empty comma part. + """ + parts = DEFAULT_SELECTOR_PARSER.split_top_level( + clause, + delimiter=",", + ) + if not parts: + return () + + if any(not part.strip() for part in clause.split(",")): + raise SortParseError( + f"Sort clause #{clause_index} {clause!r} contains empty parts.", + ) + return parts + @staticmethod def _parse_direction( raw_direction: str, @@ -188,7 +242,7 @@ def _parse_selector( SortParseError: If the selector syntax is invalid. """ try: - return self._selector_parser.parse( + return DEFAULT_SELECTOR_PARSER.parse( raw_selector, max_length=self._limits.max_field_path_length, context=f"Sort selector in clause #{clause_index}", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2112a5e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for pyrsql.""" diff --git a/tests/integration/sqlalchemy/test_async_pipeline.py b/tests/integration/sqlalchemy/test_async_pipeline.py index 94be96f..b54d967 100644 --- a/tests/integration/sqlalchemy/test_async_pipeline.py +++ b/tests/integration/sqlalchemy/test_async_pipeline.py @@ -6,11 +6,11 @@ pytest.importorskip("sqlalchemy") from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from tests.functional.fastapi_sqlalchemy.conftest import Base, User import pyrsql from pyrsql.core.page import PageRequest from pyrsql.core.sort import Sort +from tests.functional.fastapi_sqlalchemy.conftest import Base, User pytestmark = [ pytest.mark.integration, diff --git a/tests/performance/test_path_resolver_bench.py b/tests/performance/test_path_resolver_bench.py index 9b0fc4f..8bc84ab 100644 --- a/tests/performance/test_path_resolver_bench.py +++ b/tests/performance/test_path_resolver_bench.py @@ -51,7 +51,6 @@ def test_field_mapping_expansion_remains_fast( ) -> None: """Keeps field mapping expansion within budget.""" policy = FieldPolicySet( - field_mapping={"companyName": "company.name"}, field_whitelist=frozenset(), field_blacklist=frozenset(), model_field_mapping={Company: {"companyName": "name"}}, diff --git a/tests/security/conftest.py b/tests/security/conftest.py index db9619d..f9ada19 100644 --- a/tests/security/conftest.py +++ b/tests/security/conftest.py @@ -9,9 +9,9 @@ pytest.importorskip("fastapi") from fastapi import Depends, FastAPI from fastapi.testclient import TestClient -from tests.functional.fastapi_sqlalchemy.conftest import User from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration +from tests.functional.fastapi_sqlalchemy.conftest import User if TYPE_CHECKING: from collections.abc import Callable diff --git a/tests/security/test_fastapi_sqlalchemy_error_sanitization.py b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py index 262145c..f6f5c7b 100644 --- a/tests/security/test_fastapi_sqlalchemy_error_sanitization.py +++ b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING import pytest -from tests.security.conftest import assert_response_hides_internal_error_details from pyrsql.adapters.fastapi import FastAPICriteriaConfig from pyrsql.core.options import QueryOptions, SortOptions from pyrsql.parsing.limits import ParseLimits from pyrsql.sorting.limits import SortLimits +from tests.security.conftest import assert_response_hides_internal_error_details if TYPE_CHECKING: from collections.abc import Callable diff --git a/tests/security/test_sqlalchemy_injection.py b/tests/security/test_sqlalchemy_injection.py index d4c699b..c3b19c8 100644 --- a/tests/security/test_sqlalchemy_injection.py +++ b/tests/security/test_sqlalchemy_injection.py @@ -5,12 +5,12 @@ import pytest from sqlalchemy import select from sqlalchemy.dialects import postgresql -from tests.integration.sqlalchemy.conftest import JsonEvent, User import pyrsql from pyrsql.core.json.options import JSONOptions from pyrsql.core.options import QueryOptions from pyrsql.orms.sqlalchemy import SQLAlchemyORM, SQLAlchemyORMError +from tests.integration.sqlalchemy.conftest import JsonEvent, User pytestmark = [ pytest.mark.security, diff --git a/tests/security/test_sqlalchemy_jsonpath_security.py b/tests/security/test_sqlalchemy_jsonpath_security.py index f0235ff..b5b2aec 100644 --- a/tests/security/test_sqlalchemy_jsonpath_security.py +++ b/tests/security/test_sqlalchemy_jsonpath_security.py @@ -5,10 +5,10 @@ import pytest from sqlalchemy import select from sqlalchemy.dialects import postgresql -from tests.integration.sqlalchemy.conftest import JsonEvent import pyrsql from pyrsql.orms.sqlalchemy import SQLAlchemyORM +from tests.integration.sqlalchemy.conftest import JsonEvent pytestmark = [ pytest.mark.security, diff --git a/tests/unit/core/json/test_options.py b/tests/unit/core/json/test_options.py index 632d1c7..d3c6d22 100644 --- a/tests/unit/core/json/test_options.py +++ b/tests/unit/core/json/test_options.py @@ -57,6 +57,11 @@ def test_json_options_accept_function_name_overrides() -> None: r"valid SQL identifier", id="invalid-path-exists-function", ), + pytest.param( + {"path_exists_function": cast("Any", 123)}, + r"path_exists_function.*string", + id="non-string-path-exists-function", + ), pytest.param( {"path_exists_tz_function": " jsonb_path_exists_tz "}, r"outer whitespace", @@ -76,6 +81,15 @@ def test_json_options_accept_function_name_overrides() -> None: r"outer whitespace", id="whitespace-sort-field-path", ), + pytest.param( + { + "sort_field_types": { + cast("Any", 123): JSONSortScalarType.NUMERIC, + }, + }, + r"field paths must be strings", + id="non-string-sort-field-path", + ), ], ) def test_json_options_reject_invalid_public_configuration( diff --git a/tests/unit/core/json/test_path.py b/tests/unit/core/json/test_path.py index fe00db5..b915614 100644 --- a/tests/unit/core/json/test_path.py +++ b/tests/unit/core/json/test_path.py @@ -2,27 +2,9 @@ from __future__ import annotations -import pytest - from pyrsql.core.json.path import JSONPath -@pytest.mark.parametrize( - ("segments", "pattern"), - [ - pytest.param(("user", ""), r"empty", id="empty-segment"), - pytest.param((" user ",), r"outer whitespace", id="whitespace-segment"), - ], -) -def test_json_path_rejects_invalid_segments( - segments: tuple[str, ...], - pattern: str, -) -> None: - """JSON paths reject empty and whitespace-padded segments.""" - with pytest.raises(ValueError, match=pattern): - JSONPath(segments=segments) - - def test_json_path_reports_root_and_dot_path() -> None: """JSON paths expose root and dotted representations.""" root_path = JSONPath() @@ -35,6 +17,13 @@ def test_json_path_reports_root_and_dot_path() -> None: assert nested_path.to_postgresql_jsonpath() == "$.user.id" +def test_json_path_materializes_iterable_segments_to_tuple() -> None: + """JSON paths normalize iterable segments into an immutable tuple.""" + path = JSONPath(segments=["user", "id"]) + + assert path.segments == ("user", "id") + + def test_json_path_quotes_postgresql_special_segments() -> None: """JSON paths quote PostgreSQL segments that are not simple identifiers.""" path = JSONPath(segments=("user name", "x-y", 'quote"key')) diff --git a/tests/unit/core/json/test_query.py b/tests/unit/core/json/test_query.py index 503fa5e..67eae07 100644 --- a/tests/unit/core/json/test_query.py +++ b/tests/unit/core/json/test_query.py @@ -58,25 +58,16 @@ def test_json_path_comparison_uses_shared_default_normalizer() -> None: assert comparison.values == (expected,) -@pytest.mark.parametrize( - ("operator_name", "pattern"), - [ - pytest.param("", r"cannot be empty", id="empty-operator-name"), - pytest.param( - " equal ", - r"outer whitespace", - id="whitespace-operator-name", - ), - ], -) -def test_json_path_comparison_rejects_invalid_operator_names( - operator_name: str, - pattern: str, -) -> None: - """JSON comparisons reject invalid operator names.""" - with pytest.raises(ValueError, match=pattern): - JSONPathComparison( - path=JSONPath(segments=("user", "id")), - operator_name=operator_name, - values=(), - ) +def test_json_path_comparison_retains_normalized_payload() -> None: + """JSON comparisons retain the normalized JSON comparison payload.""" + path = JSONPath(segments=("user", "id")) + value = DEFAULT_JSON_SCALAR_NORMALIZER.normalize("1", quoted=False) + comparison = JSONPathComparison( + path=path, + operator_name="equal", + values=(value,), + ) + + assert comparison.path is path + assert comparison.operator_name == "equal" + assert comparison.values == (value,) diff --git a/tests/unit/core/json/test_values.py b/tests/unit/core/json/test_values.py index 4bbaf34..2860e5c 100644 --- a/tests/unit/core/json/test_values.py +++ b/tests/unit/core/json/test_values.py @@ -29,3 +29,44 @@ def test_json_scalar_normalizer_parses_supported_values( normalizer = JSONScalarNormalizer() assert normalizer.normalize(raw_value, quoted=quoted).value == expected + + +@pytest.mark.parametrize( + ("raw_value", "expected_literal"), + [ + pytest.param("true", '"true"', id="quoted-bool"), + pytest.param("null", '"null"', id="quoted-null"), + pytest.param("123", '"123"', id="quoted-int"), + pytest.param("3.14", '"3.14"', id="quoted-float"), + ], +) +def test_json_scalar_normalizer_keeps_quoted_scalars_as_strings( + raw_value: str, + expected_literal: str, +) -> None: + """Quoted scalar literals remain strings instead of coercing as JSON.""" + normalized = JSONScalarNormalizer().normalize(raw_value, quoted=True) + + assert normalized.value == raw_value + assert normalized.json_literal == expected_literal + assert normalized.python_type is str + + +@pytest.mark.parametrize( + ("raw_value", "expected_value", "expected_literal"), + [ + pytest.param("1e309", "1e309", '"1e309"', id="out-of-range-float"), + pytest.param("01", "01", '"01"', id="invalid-json-leading-zero"), + ], +) +def test_json_scalar_normalizer_keeps_invalid_json_numbers_as_strings( + raw_value: str, + expected_value: str, + expected_literal: str, +) -> None: + """Invalid JSON numbers remain plain strings instead of coercing badly.""" + normalized = JSONScalarNormalizer().normalize(raw_value, quoted=False) + + assert normalized.value == expected_value + assert normalized.json_literal == expected_literal + assert normalized.python_type is str diff --git a/tests/unit/core/test_conversion.py b/tests/unit/core/test_conversion.py index 5cde9e9..46231f7 100644 --- a/tests/unit/core/test_conversion.py +++ b/tests/unit/core/test_conversion.py @@ -12,6 +12,7 @@ from pyrsql.core.conversion import ( DEFAULT_VALUE_CONVERTER_REGISTRY, + FieldValueConverterSet, ValueConversionError, ValueConverterRegistry, ) @@ -188,6 +189,7 @@ def test_default_registry_rewraps_json_container_subclasses( [ pytest.param('["a","b"]', dict, id="list-to-dict"), pytest.param('{"a":1}', list, id="dict-to-list"), + pytest.param("{invalid", dict, id="invalid-json-dict"), pytest.param("invalid", bool, id="invalid-bool"), pytest.param("2026-13-02", dt.date, id="invalid-date"), pytest.param("25:15:30", dt.time, id="invalid-time"), @@ -212,3 +214,38 @@ def _to_upper(raw: str) -> str: registry = ValueConverterRegistry({}).with_converter(str, _to_upper) assert registry.convert("demo", str) == "DEMO" + + +def test_registry_wraps_non_domain_errors_from_custom_converters() -> None: + """Normalizes unexpected custom converter failures into domain errors.""" + + def _broken_converter(raw: str) -> str: + raise RuntimeError(raw) + + registry = ValueConverterRegistry({str: _broken_converter}) + + with pytest.raises(ValueConversionError, match="Failed to convert 'demo'"): + registry.convert("demo", str) + + +class ExampleModel: + """Simple model marker used for field-converter resolution tests.""" + + +def test_field_value_converter_set_prefers_model_specific_converter() -> None: + """Resolves model-scoped converters before global field-path converters.""" + converters = FieldValueConverterSet( + field_converters={"payload.value": lambda raw: raw.upper()}, + model_field_converters={ + ExampleModel: {"value": lambda raw: raw.lower()}, + }, + ) + + resolved = converters.resolve( + model=ExampleModel, + field_name="value", + field_path="payload.value", + ) + + assert resolved is not None + assert resolved("MiXeD") == "mixed" diff --git a/tests/unit/core/test_custom.py b/tests/unit/core/test_custom.py new file mode 100644 index 0000000..052df1e --- /dev/null +++ b/tests/unit/core/test_custom.py @@ -0,0 +1,56 @@ +"""Unit tests for ORM-neutral custom predicate definitions.""" + +from __future__ import annotations + +import pytest + +from pyrsql.core.custom import CustomPredicateDefinition +from pyrsql.parsing.operators import ComparisonOperator + + +def test_custom_predicate_definition_stores_valid_runtime_contract() -> None: + """Preserves the configured operator and argument type.""" + definition = CustomPredicateDefinition( + operator=ComparisonOperator( + name="all_match", + spellings=("=all=",), + minimum_arguments=1, + maximum_arguments=1, + ), + argument_type=str, + ) + + assert definition.operator.name == "all_match" + assert definition.argument_type is str + + +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"operator": object(), "argument_type": str}, + "ComparisonOperator", + id="invalid-operator", + ), + pytest.param( + { + "operator": ComparisonOperator( + name="all_match", + spellings=("=all=",), + minimum_arguments=1, + maximum_arguments=1, + ), + "argument_type": "str", + }, + "runtime type", + id="invalid-argument-type", + ), + ], +) +def test_custom_predicate_definition_rejects_invalid_runtime_contract( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Rejects invalid custom predicate contract inputs.""" + with pytest.raises(TypeError, match=pattern): + CustomPredicateDefinition(**kwargs) diff --git a/tests/unit/core/test_options.py b/tests/unit/core/test_options.py index dbed6a3..f7c04a1 100644 --- a/tests/unit/core/test_options.py +++ b/tests/unit/core/test_options.py @@ -174,8 +174,8 @@ def test_query_options_cache_derived_policy_objects() -> None: [ pytest.param( QueryOptions(field_mapping={"alias": "name"}), - False, - id="query-policy-not-empty", + True, + id="query-global-field-mapping-kept-out-of-policy", ), pytest.param( SortOptions(model_field_whitelist={str: frozenset({"name"})}), @@ -192,6 +192,25 @@ def test_options_mark_field_policy_non_empty_when_restrictions_exist( assert options.field_policy.is_empty is expected_empty +def test_query_options_keep_global_field_mapping_out_of_field_policy() -> None: + """Keeps global field aliases out of the runtime field policy.""" + options = QueryOptions(field_mapping={"alias": "name"}) + + assert options.field_mapping["alias"] == "name" + assert options.field_policy is DEFAULT_FIELD_POLICY_SET + + +def test_field_policy_prefers_blacklist_over_whitelist() -> None: + """Blocked fields stay blocked even when also whitelisted.""" + options = QueryOptions( + field_whitelist=frozenset({"name"}), + field_blacklist=frozenset({"name"}), + ) + + with pytest.raises(ValueError, match=r"blocked"): + options.field_policy.validate_global_field_access("name") + + def test_sort_options_are_normalized() -> None: """Normalizes simple sort option containers into immutable values.""" options = SortOptions( @@ -228,6 +247,32 @@ def test_procedure_policy_compiles_regex_rules() -> None: assert policy.is_blacklisted("lower") is True +def test_procedure_policy_empty_lists_short_circuit() -> None: + """Empty whitelist/blacklist patterns short-circuit to False.""" + policy = ProcedureAccessPolicy.from_patterns(whitelist=(), blacklist=()) + + assert policy.is_whitelisted("upper") is False + assert policy.is_blacklisted("upper") is False + + +def test_procedure_policy_rejects_invalid_regex_pattern() -> None: + """Raises a contextual error for invalid regex configuration.""" + with pytest.raises(ValueError, match=r"(?i)whitelist.*regex"): + ProcedureAccessPolicy.from_patterns( + whitelist=("[",), + blacklist=(), + ) + + +def test_procedure_policy_rejects_uncompiled_pattern_payload() -> None: + """Rejects direct construction with non-compiled regex payloads.""" + with pytest.raises(TypeError, match=r"whitelist_patterns"): + ProcedureAccessPolicy( + whitelist_patterns=("upper",), # type: ignore[arg-type] + blacklist_patterns=(), + ) + + def test_query_options_extend_operator_registry_with_custom_operator() -> None: """Extends operator registry through the configured options.""" all_match = ComparisonOperator( diff --git a/tests/unit/ir/test_page.py b/tests/unit/ir/test_page.py index 028d09e..c4664b4 100644 --- a/tests/unit/ir/test_page.py +++ b/tests/unit/ir/test_page.py @@ -2,8 +2,6 @@ from __future__ import annotations -import pytest - from pyrsql.ir.page import BoundPage @@ -13,20 +11,3 @@ def test_bound_page_exposes_offset_and_limit() -> None: assert page.offset == 50 assert page.limit == 25 - - -@pytest.mark.parametrize( - ("page_number", "page_size", "pattern"), - [ - pytest.param(-1, 10, r"page_number", id="negative-page-number"), - pytest.param(0, 0, r"page_size", id="non-positive-page-size"), - ], -) -def test_bound_page_rejects_invalid_values( - page_number: int, - page_size: int, - pattern: str, -) -> None: - """Rejects invalid pagination values.""" - with pytest.raises(ValueError, match=pattern): - BoundPage(page_number=page_number, page_size=page_size) diff --git a/tests/unit/ir/test_query.py b/tests/unit/ir/test_query.py index 5f6091b..f360614 100644 --- a/tests/unit/ir/test_query.py +++ b/tests/unit/ir/test_query.py @@ -90,20 +90,6 @@ def test_bound_logical_walks_descendants_depth_first() -> None: @pytest.mark.parametrize( ("factory", "pattern"), [ - pytest.param( - lambda: BoundField( - raw_path="username", - field_path="user.name", - segments=("user",), - ), - r"segments must match", - id="field-segments-mismatch", - ), - pytest.param( - lambda: BoundFunction(function_name="upper", arguments=()), - r"at least one argument", - id="function-without-arguments", - ), pytest.param( lambda: BoundLogical( span=_span(), @@ -119,6 +105,6 @@ def test_bound_query_nodes_reject_invalid_construction( factory: object, pattern: str, ) -> None: - """Rejects invalid bound query node invariants.""" + """Rejects invalid logical invariants in bound query nodes.""" with pytest.raises(ValueError, match=pattern): factory() # type: ignore[operator] diff --git a/tests/unit/ir/test_sort.py b/tests/unit/ir/test_sort.py index c3367da..e61137f 100644 --- a/tests/unit/ir/test_sort.py +++ b/tests/unit/ir/test_sort.py @@ -52,21 +52,12 @@ def test_bound_sort_supports_function_selectors() -> None: r"at least one bound sort field", id="empty-sort", ), - pytest.param( - lambda: BoundSortField( - selector=_bound_field("companyName", "company.name"), - direction=SortDirection.ASCENDING, - ignore_case="invalid", # type: ignore[arg-type] - ), - r"ignore_case must be a bool", - id="invalid-ignore-case", - ), ], ) def test_bound_sort_nodes_reject_invalid_construction( factory: object, pattern: str, ) -> None: - """Rejects invalid bound sort invariants.""" - with pytest.raises((TypeError, ValueError), match=pattern): + """Rejects invalid structural bound sort invariants.""" + with pytest.raises(ValueError, match=pattern): factory() # type: ignore[operator] diff --git a/tests/unit/orms/sqlalchemy/test_resolver.py b/tests/unit/orms/sqlalchemy/test_resolver.py index 4716623..001d690 100644 --- a/tests/unit/orms/sqlalchemy/test_resolver.py +++ b/tests/unit/orms/sqlalchemy/test_resolver.py @@ -261,7 +261,6 @@ def test_path_resolver_applies_model_field_mapping( User, "company.companyName", field_policy=FieldPolicySet( - field_mapping={}, field_whitelist=frozenset(), field_blacklist=frozenset(), model_field_mapping={Company: {"companyName": "name"}}, @@ -283,7 +282,6 @@ def test_path_resolver_enforces_model_field_whitelist( User, "company.name", field_policy=FieldPolicySet( - field_mapping={}, field_whitelist=frozenset(), field_blacklist=frozenset(), model_field_mapping={}, diff --git a/tests/unit/parsing/test_ast.py b/tests/unit/parsing/test_ast.py new file mode 100644 index 0000000..819906f --- /dev/null +++ b/tests/unit/parsing/test_ast.py @@ -0,0 +1,66 @@ +"""Unit tests for parsing AST models.""" + +from __future__ import annotations + +from pyrsql.parsing.ast import ( + ComparisonNode, + LogicalNode, + LogicalOperator, +) +from pyrsql.parsing.operators import EQUAL +from pyrsql.parsing.source import SourcePosition, SourceSpan +from pyrsql.selector.ast import FieldSelector + + +def _span() -> SourceSpan: + """Builds one valid source span for AST tests.""" + return SourceSpan( + start=SourcePosition(index=0, line=1, column=1), + end=SourcePosition(index=4, line=1, column=5), + ) + + +def test_comparison_node_with_span_reuses_existing_payload() -> None: + """Comparison nodes can be copied with a replacement span.""" + original = ComparisonNode( + span=_span(), + selector=FieldSelector(raw_path="name", segments=("name",)), + operator=EQUAL, + arguments=(), + ) + replacement = SourceSpan( + start=SourcePosition(index=10, line=2, column=1), + end=SourcePosition(index=14, line=2, column=5), + ) + + copied = original.with_span(replacement) + + assert copied.span == replacement + assert copied.selector is original.selector + assert copied.operator is original.operator + assert copied.arguments is original.arguments + + +def test_logical_node_with_span_reuses_existing_children() -> None: + """Logical nodes can be copied with a replacement span.""" + child = ComparisonNode( + span=_span(), + selector=FieldSelector(raw_path="name", segments=("name",)), + operator=EQUAL, + arguments=(), + ) + original = LogicalNode( + span=_span(), + operator=LogicalOperator.AND, + children=(child,), + ) + replacement = SourceSpan( + start=SourcePosition(index=10, line=2, column=1), + end=SourcePosition(index=20, line=2, column=11), + ) + + copied = original.with_span(replacement) + + assert copied.span == replacement + assert copied.operator is original.operator + assert copied.children is original.children diff --git a/tests/unit/parsing/test_lexer.py b/tests/unit/parsing/test_lexer.py index e1cefe4..ba677cd 100644 --- a/tests/unit/parsing/test_lexer.py +++ b/tests/unit/parsing/test_lexer.py @@ -131,6 +131,38 @@ def test_lexer_uses_shared_default_limits_instance() -> None: assert lexer.limits is DEFAULT_PARSE_LIMITS +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"source": 123}, + r"string or SourceText", + id="invalid-source", + ), + pytest.param( + {"source": "name==demo", "limits": object()}, + r"ParseLimits instance", + id="invalid-limits", + ), + pytest.param( + { + "source": "name==demo", + "operator_registry": object(), + }, + r"OperatorRegistry instance", + id="invalid-operator-registry", + ), + ], +) +def test_lexer_rejects_invalid_runtime_dependencies( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Lexer validates public constructor dependencies eagerly.""" + with pytest.raises(TypeError, match=pattern): + Lexer(**kwargs) # type: ignore[arg-type] + + def test_lexer_skips_operator_matching_for_non_prefix_characters() -> None: """Avoids scanning every operator when the prefix cannot match any.""" assert DEFAULT_OPERATOR_REGISTRY.match_candidates("n") == () diff --git a/tests/unit/parsing/test_operators.py b/tests/unit/parsing/test_operators.py new file mode 100644 index 0000000..f21914d --- /dev/null +++ b/tests/unit/parsing/test_operators.py @@ -0,0 +1,105 @@ +"""Unit tests for parsing operator models.""" + +from __future__ import annotations + +import pytest + +from pyrsql.parsing.operators import ComparisonOperator, OperatorRegistry + + +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + { + "name": None, + "spellings": ("==",), + "minimum_arguments": 1, + "maximum_arguments": 1, + }, + r"name must be a string", + id="non-string-name", + ), + pytest.param( + { + "name": " equal ", + "spellings": ("==",), + "minimum_arguments": 1, + "maximum_arguments": 1, + }, + r"outer whitespace", + id="whitespace-name", + ), + pytest.param( + { + "name": "equal", + "spellings": ["=="], + "minimum_arguments": 1, + "maximum_arguments": 1, + }, + r"tuple of strings", + id="non-tuple-spellings", + ), + pytest.param( + { + "name": "equal", + "spellings": (" =eq= ",), + "minimum_arguments": 1, + "maximum_arguments": 1, + }, + r"outer whitespace", + id="whitespace-spelling", + ), + pytest.param( + { + "name": "equal", + "spellings": ("==",), + "minimum_arguments": True, + "maximum_arguments": 1, + }, + r"minimum_arguments", + id="bool-minimum-arguments", + ), + ], +) +def test_comparison_operator_rejects_invalid_runtime_payloads( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Comparison operators enforce a strict runtime contract.""" + with pytest.raises((TypeError, ValueError), match=pattern): + ComparisonOperator(**kwargs) + + +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"operators": ["bad"]}, + r"tuple of ComparisonOperator", + id="non-tuple-operators", + ), + pytest.param( + { + "operators": ( + ComparisonOperator( + name="equal", + spellings=("==",), + minimum_arguments=1, + maximum_arguments=1, + ), + "bad", + ), + }, + r"ComparisonOperator instances", + id="invalid-operator-entry", + ), + ], +) +def test_operator_registry_rejects_invalid_runtime_payloads( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Operator registries enforce a strict runtime contract.""" + with pytest.raises((TypeError, ValueError), match=pattern): + OperatorRegistry(**kwargs) diff --git a/tests/unit/parsing/test_parser.py b/tests/unit/parsing/test_parser.py index 4574dec..57d0fc2 100644 --- a/tests/unit/parsing/test_parser.py +++ b/tests/unit/parsing/test_parser.py @@ -124,6 +124,12 @@ def test_parser_accepts_custom_operator_registry() -> None: @pytest.mark.parametrize( ("source", "limits", "pattern"), [ + pytest.param( + "()", + None, + r"Grouped expression cannot be empty", + id="empty-group", + ), pytest.param( "name==", None, @@ -154,6 +160,35 @@ def test_parse_limits_reject_invalid_values() -> None: ParseLimits(max_query_length=0) +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"max_query_length": "10"}, + r"max_query_length", + id="string-query-limit", + ), + pytest.param( + {"max_selector_length": 1.5}, + r"max_selector_length", + id="float-selector-limit", + ), + pytest.param( + {"max_argument_length": True}, + r"max_argument_length", + id="bool-argument-limit", + ), + ], +) +def test_parse_limits_reject_non_integer_values( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Rejects parser limits that are not strict integers.""" + with pytest.raises(TypeError, match=pattern): + ParseLimits(**kwargs) + + def test_parser_uses_shared_default_limits_instance() -> None: """Reuses the shared default limits on the common parse path.""" parser = Parser("name==demo") @@ -161,14 +196,52 @@ def test_parser_uses_shared_default_limits_instance() -> None: assert parser.limits is DEFAULT_PARSE_LIMITS -def test_logical_node_rejects_single_child() -> None: - """Prevents invalid logical AST nodes.""" - comparison = Parser("name==demo").parse() - assert isinstance(comparison, ComparisonNode) +def test_parser_counts_expression_depth_semantically() -> None: + """Applies depth limits to nesting, not parser helper call depth.""" + assert ( + Parser( + "name==demo", + limits=ParseLimits(max_expression_depth=1), + ).parse() + is not None + ) + assert ( + Parser( + "(name==demo)", + limits=ParseLimits(max_expression_depth=2), + ).parse() + is not None + ) + + with pytest.raises(ParseError, match="maximum supported expression depth"): + Parser( + "((name==demo))", + limits=ParseLimits(max_expression_depth=2), + ).parse() + - with pytest.raises(ValueError, match="at least two child expressions"): - LogicalNode( - span=comparison.span, - operator=LogicalOperator.AND, - children=(comparison,), - ) +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"source": "name==demo", "limits": object()}, + r"ParseLimits instance", + id="invalid-limits", + ), + pytest.param( + { + "source": "name==demo", + "operator_registry": object(), + }, + r"OperatorRegistry instance", + id="invalid-operator-registry", + ), + ], +) +def test_parser_rejects_invalid_runtime_dependencies( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Parser validates public constructor dependencies eagerly.""" + with pytest.raises(TypeError, match=pattern): + Parser(**kwargs) # type: ignore[arg-type] diff --git a/tests/unit/parsing/test_source.py b/tests/unit/parsing/test_source.py new file mode 100644 index 0000000..a7f5fdf --- /dev/null +++ b/tests/unit/parsing/test_source.py @@ -0,0 +1,27 @@ +"""Unit tests for parsing source location helpers.""" + +from __future__ import annotations + +from pyrsql.parsing.source import SourcePosition, SourceSpan, SourceText + + +def test_source_span_cover_uses_outer_boundaries() -> None: + """Source span cover uses the first start and last end positions.""" + start = SourceSpan( + start=SourcePosition(index=0, line=1, column=1), + end=SourcePosition(index=4, line=1, column=5), + ) + end = SourceSpan( + start=SourcePosition(index=5, line=1, column=6), + end=SourcePosition(index=9, line=1, column=10), + ) + + covered = SourceSpan.cover(start, end) + + assert covered.start == start.start + assert covered.end == end.end + + +def test_source_text_slice_returns_expected_substring() -> None: + """Source slices preserve normal behavior for valid bounds.""" + assert SourceText(text="name==demo").slice(0, 4) == "name" diff --git a/tests/unit/selector/test_ast.py b/tests/unit/selector/test_ast.py new file mode 100644 index 0000000..543cefa --- /dev/null +++ b/tests/unit/selector/test_ast.py @@ -0,0 +1,31 @@ +"""Unit tests for selector AST models.""" + +from __future__ import annotations + +from pyrsql.selector.ast import FieldSelector, FunctionSelector, LiteralSelector + + +def test_function_selector_walk_traverses_nested_arguments() -> None: + """Function selectors walk nested selectors depth-first.""" + nested = FunctionSelector( + function_name="lower", + arguments=( + FieldSelector( + raw_path="user.name", + segments=("user", "name"), + ), + ), + ) + selector = FunctionSelector( + function_name="upper", + arguments=(nested, LiteralSelector(value="x")), + ) + + walked = tuple(selector.walk()) + + assert walked == ( + selector, + nested, + nested.arguments[0], + selector.arguments[1], + ) diff --git a/tests/unit/selector/test_parser.py b/tests/unit/selector/test_parser.py index 0626fa7..254efaf 100644 --- a/tests/unit/selector/test_parser.py +++ b/tests/unit/selector/test_parser.py @@ -30,6 +30,43 @@ def test_split_top_level_normalizes_fragments( assert parser.split_top_level(text, delimiter=delimiter) == expected +@pytest.mark.parametrize( + ("text", "delimiter", "expected_error", "pattern"), + [ + pytest.param( + None, + "|", + TypeError, + r"text must be a string", + id="non-string-text", + ), + pytest.param( + "a|b", + "", + ValueError, + r"single character", + id="empty-delimiter", + ), + pytest.param( + "a||b", + "||", + ValueError, + r"single character", + id="multi-character-delimiter", + ), + ], +) +def test_split_top_level_rejects_invalid_runtime_inputs( + text: object, + delimiter: object, + expected_error: type[Exception], + pattern: str, +) -> None: + """Top-level splitting enforces a strict runtime contract.""" + with pytest.raises(expected_error, match=pattern): + SelectorParser().split_top_level(text, delimiter=delimiter) # type: ignore[arg-type] + + @pytest.mark.parametrize( ("raw_selector", "expected_type", "expected_value"), [ @@ -108,6 +145,24 @@ def test_parse_function_selector_walks_nested_arguments() -> None: SelectorParseError, id="invalid-function-selector", ), + pytest.param( + "@upper[name||#1]", + r"cannot contain empty arguments", + SelectorParseError, + id="empty-function-argument-middle", + ), + pytest.param( + "@upper[|name]", + r"cannot contain empty arguments", + SelectorParseError, + id="empty-function-argument-leading", + ), + pytest.param( + "@upper[name|]", + r"cannot contain empty arguments", + SelectorParseError, + id="empty-function-argument-trailing", + ), ], ) def test_parse_rejects_invalid_selectors( @@ -122,3 +177,48 @@ def test_parse_rejects_invalid_selectors( max_length=100, context="selector", ) + + +@pytest.mark.parametrize( + ("kwargs", "expected_error", "pattern"), + [ + pytest.param( + {"raw_selector": "name", "max_length": 1.5, "context": "selector"}, + TypeError, + r"max_length must be an integer", + id="non-integer-max-length", + ), + pytest.param( + {"raw_selector": "name", "max_length": True, "context": "selector"}, + TypeError, + r"max_length must be an integer", + id="bool-max-length", + ), + pytest.param( + {"raw_selector": "name", "max_length": 0, "context": "selector"}, + ValueError, + r"greater than 0", + id="non-positive-max-length", + ), + pytest.param( + {"raw_selector": "name", "max_length": 10, "context": None}, + TypeError, + r"context must be a string", + id="non-string-context", + ), + pytest.param( + {"raw_selector": "name", "max_length": 10, "context": ""}, + ValueError, + r"context cannot be empty", + id="empty-context", + ), + ], +) +def test_parse_rejects_invalid_runtime_inputs( + kwargs: dict[str, object], + expected_error: type[Exception], + pattern: str, +) -> None: + """Selector parsing rejects invalid runtime inputs early.""" + with pytest.raises(expected_error, match=pattern): + SelectorParser().parse(**kwargs) # type: ignore[arg-type] diff --git a/tests/unit/semantic/test_binder.py b/tests/unit/semantic/test_binder.py index 529dfa9..a17bd62 100644 --- a/tests/unit/semantic/test_binder.py +++ b/tests/unit/semantic/test_binder.py @@ -181,6 +181,32 @@ def test_semantic_binder_enforces_function_policies( SemanticBinder(options).bind(expression) +def test_semantic_binder_prefers_blacklist_over_whitelist_for_fields() -> None: + """Explicit field blacklist takes precedence over whitelist membership.""" + expression = _parse("name==demo") + options = _SemanticOptions( + field_whitelist=frozenset({"name"}), + field_blacklist=frozenset({"name"}), + ) + + with pytest.raises(FieldBlacklistedError, match=r"blocked"): + SemanticBinder(options).bind(expression) + + +def test_semantic_binder_prefers_blacklist_over_whitelist_for_functions() -> ( + None +): + """Explicit function blacklist takes precedence over whitelist matches.""" + expression = _parse("@upper[name]==demo") + options = _SemanticOptions( + procedure_whitelist=("upper",), + procedure_blacklist=("upper",), + ) + + with pytest.raises(FunctionBlacklistedError, match=r"blacklisted"): + SemanticBinder(options).bind(expression) + + def test_semantic_errors_expose_structured_diagnostic() -> None: """Exposes structured diagnostics from semantic errors.""" expression = _parse("@upper[name]==demo") diff --git a/tests/unit/sorting/test_ast.py b/tests/unit/sorting/test_ast.py new file mode 100644 index 0000000..007ca37 --- /dev/null +++ b/tests/unit/sorting/test_ast.py @@ -0,0 +1,26 @@ +"""Unit tests for sorting AST models.""" + +from __future__ import annotations + +from pyrsql.selector.ast import FieldSelector +from pyrsql.sorting.ast import SortDirection, SortField + + +def test_sort_direction_from_raw_matches_supported_values() -> None: + """Sort directions normalize supported raw tokens.""" + assert SortDirection.from_raw("asc") is SortDirection.ASCENDING + assert SortDirection.from_raw("desc") is SortDirection.DESCENDING + + +def test_sort_field_retains_constructor_values() -> None: + """Sort fields retain the parsed sort metadata.""" + selector = FieldSelector(raw_path="name", segments=("name",)) + field = SortField( + selector=selector, + direction=SortDirection.ASCENDING, + ignore_case=False, + ) + + assert field.selector is selector + assert field.direction is SortDirection.ASCENDING + assert field.ignore_case is False diff --git a/tests/unit/sorting/test_binder.py b/tests/unit/sorting/test_binder.py index 2c0e349..7f6b49b 100644 --- a/tests/unit/sorting/test_binder.py +++ b/tests/unit/sorting/test_binder.py @@ -155,3 +155,27 @@ def test_sort_binder_enforces_function_policies( with pytest.raises(expected_error): SortBinder(options).bind(fields) + + +def test_sort_binder_prefers_blacklist_over_whitelist_for_fields() -> None: + """Explicit field blacklist takes precedence over whitelist membership.""" + fields = SortParser("name").parse() + options = _SortOptions( + field_whitelist=frozenset({"name"}), + field_blacklist=frozenset({"name"}), + ) + + with pytest.raises(SortFieldBlacklistedError, match=r"blocked"): + SortBinder(options).bind(fields) + + +def test_sort_binder_prefers_blacklist_over_whitelist_for_functions() -> None: + """Explicit function blacklist takes precedence over whitelist matches.""" + fields = SortParser("@upper[name],asc").parse() + options = _SortOptions( + procedure_whitelist=("upper",), + procedure_blacklist=("upper",), + ) + + with pytest.raises(SortFunctionBlacklistedError, match=r"blacklisted"): + SortBinder(options).bind(fields) diff --git a/tests/unit/sorting/test_parser.py b/tests/unit/sorting/test_parser.py index d9a9d69..c9dd989 100644 --- a/tests/unit/sorting/test_parser.py +++ b/tests/unit/sorting/test_parser.py @@ -82,9 +82,54 @@ def test_sort_parser_supports_function_selectors() -> None: assert selector.arguments[1].value == 123 +@pytest.mark.parametrize( + ("kwargs", "expected_error", "pattern"), + [ + pytest.param( + {"source": 123}, + TypeError, + r"source must be a string or None", + id="non-string-source", + ), + pytest.param( + {"source": "name", "limits": "bad"}, + TypeError, + r"limits must be a SortLimits instance", + id="invalid-limits-type", + ), + ], +) +def test_sort_parser_rejects_invalid_runtime_inputs( + kwargs: dict[str, object], + expected_error: type[Exception], + pattern: str, +) -> None: + """Sort parser rejects invalid runtime inputs early.""" + with pytest.raises(expected_error, match=pattern): + SortParser(**kwargs).parse() # type: ignore[arg-type] + + @pytest.mark.parametrize( ("source", "limits", "pattern"), [ + pytest.param( + ",asc", + None, + r"contains empty parts", + id="empty-selector-part", + ), + pytest.param( + "name,", + None, + r"contains empty parts", + id="empty-trailing-part", + ), + pytest.param( + "name,,ic", + None, + r"contains empty parts", + id="empty-middle-part", + ), pytest.param( "name,sideways", None, @@ -127,6 +172,35 @@ def test_sort_limits_reject_invalid_values() -> None: SortLimits(max_sort_length=0) +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"max_sort_length": "10"}, + r"max_sort_length", + id="string-sort-length", + ), + pytest.param( + {"max_fields": 1.5}, + r"max_fields", + id="float-max-fields", + ), + pytest.param( + {"max_field_path_length": True}, + r"max_field_path_length", + id="bool-field-path-length", + ), + ], +) +def test_sort_limits_reject_non_integer_values( + kwargs: dict[str, object], + pattern: str, +) -> None: + """Rejects sort limits that are not strict integers.""" + with pytest.raises(TypeError, match=pattern): + SortLimits(**kwargs) + + def test_sort_parse_error_exposes_structured_diagnostic() -> None: """Exposes a structured diagnostic on sort parsing failures.""" with pytest.raises(SortParseError) as exc_info: diff --git a/uv.lock b/uv.lock index 5608d56..2968953 100644 --- a/uv.lock +++ b/uv.lock @@ -3,9 +3,7 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version < '3.15'", ] [[package]] @@ -51,40 +49,42 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.3.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, - { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, - { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, - { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, - { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -111,11 +111,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -225,14 +225,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -246,115 +246,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" +version = "7.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] [package.optional-dependencies] @@ -367,7 +367,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -376,7 +376,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -385,9 +385,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -404,56 +404,72 @@ wheels = [ [[package]] name = "greenlet" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/03/84359833f7e1d49a883e92777637c592306030e30cee5e2b1e6476f95c88/greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a", size = 283502, upload-time = "2026-04-27T12:20:55.213Z" }, - { url = "https://files.pythonhosted.org/packages/25/ce/6f9f008266273aa14a2e011945797ac5802b97b8b40efe7afe1ee6c1afc9/greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f", size = 600508, upload-time = "2026-04-27T12:52:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/b0f3272c2368ea2c1aa19a5ad70db0be8f8dff6e6d3d1eb82efa00cbcf19/greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb", size = 613283, upload-time = "2026-04-27T12:59:37.957Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ac/0b509b6fb93551ce5a01612ee1acda7f7dda4bbb66c99aeb2ab403d205dc/greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb", size = 613418, upload-time = "2026-04-27T12:25:23.852Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/2b2b680ec87aaa97998fb5b8d76658d4d3560386864f17efab33ba7c2e24/greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977", size = 1572229, upload-time = "2026-04-27T12:53:23.509Z" }, - { url = "https://files.pythonhosted.org/packages/61/e4/42b259e7a19aff1a270a4bd82caf6353109ed6860c9454e18f37162b83ae/greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0", size = 1639886, upload-time = "2026-04-27T12:25:22.325Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b4/733ca47b883b67c57f90d3ecb21055c9ec753597d10754ac201644061f9d/greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858", size = 237795, upload-time = "2026-04-27T12:21:40.118Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, - { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, - { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", size = 283510, upload-time = "2026-05-20T13:12:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", size = 599696, upload-time = "2026-05-20T14:00:02.906Z" }, + { url = "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", size = 612618, upload-time = "2026-05-20T14:05:39.202Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", size = 612947, upload-time = "2026-05-20T13:14:23.469Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", size = 1571425, upload-time = "2026-05-20T14:02:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", size = 1638688, upload-time = "2026-05-20T13:14:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", size = 237763, upload-time = "2026-05-20T13:11:35.659Z" }, + { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, + { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] [[package]] @@ -504,11 +520,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] @@ -898,7 +914,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -908,51 +924,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/1e/9983d2d5b5d2dc3677177bcf0fa6b25185ecf750cc0559e02199625a31c5/mypy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65d6f22d643bccaeb182d41d2a9f0990a05a871673c4ae3f97d4931eca0d2294", size = 14663140, upload-time = "2026-05-06T19:25:59.474Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/b4009c91d3ced13c8f406acf47bbe56365025cd21bf6585cd1e87375a708/mypy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:106650bce72114f43019bf72197296f51c2cd47adfa9d073ea2976c247a404c5", size = 13526733, upload-time = "2026-05-06T19:22:56.425Z" }, - { url = "https://files.pythonhosted.org/packages/f0/99/2403cb0ceeb1552f70e70e779e3d0713b24f84c7ca0e9e14b2b7bc684cf0/mypy-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c734b7eb89a4cc4ec347f8187ffa730e2b59693407bc93dcb878183037f80a17", size = 13951940, upload-time = "2026-05-06T19:24:43.45Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f7/4848a14c2667b6eb62841c9aeb7e1f6479613b1ef9a65564fe1f5518a35b/mypy-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9e60388944d0f1432a2419ab938a78d5658df1d143a7172cfe1a197276cf49", size = 14833983, upload-time = "2026-05-06T19:23:16.827Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/c51831f9f1c6e46cbce765bd0a18981b84696e40bd1eea14e0a08494af44/mypy-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95e3890666c3be41af7a7179f4872341c08e90c161ba8e7a08a21f9be92c131", size = 15135591, upload-time = "2026-05-06T19:24:32.96Z" }, - { url = "https://files.pythonhosted.org/packages/40/7f/3c25e503a94f9ec18352464551bc6c506dee2bca93c6d0e0b5568eefc269/mypy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e8709ce1b1046b8aad77a506dd01491157102dd727128c0b374b5025c7d769", size = 10983019, upload-time = "2026-05-06T19:20:30.942Z" }, - { url = "https://files.pythonhosted.org/packages/75/da/5cf833fd3b53fd4b5797e55dc16fb7efab16fddbc7205d49ff65b15d554e/mypy-2.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:0165968759c99ab79dc1a9f8aaec18e93a1bedcf7c13edd70e68dd3d5faf17cb", size = 9914165, upload-time = "2026-05-06T19:21:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1e/268b81393b81d64683f670680215553e70ae92c55805915b3440080e05e4/mypy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17b7222e9fdfd352e61fb3131da117e55cc465f701ff232f1bd97a02bbad91f", size = 14580849, upload-time = "2026-05-06T19:23:06.567Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/d159a8002d9e5c44e59ece9d641a26956c89be5b6827f819d9a9dc678c65/mypy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0a61adea1a5ffc2d47a4dc4bb180d8103f477fc2a90a1cdcbb168c2cc6caff", size = 13444955, upload-time = "2026-05-06T19:25:11.982Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5d/3b28d5a2799591da0ee5490418e94497eaf5d701e42d8b001b5e17a9b3d6/mypy-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8578f857b519993d065e5805290b71467ebfae772407a5f57e823755e4fdb850", size = 13873124, upload-time = "2026-05-06T19:20:39.684Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/f40f723955617b814d5ddc1154d8938b77aaf6926c2dbf72846e8943a0b7/mypy-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33f668a37a650df60f7b825c1ac61e6baadd4ac3c89519e929badde58d28edf5", size = 14748822, upload-time = "2026-05-06T19:25:30.972Z" }, - { url = "https://files.pythonhosted.org/packages/d6/16/eded971224a483e422a141ffd580c00e1b919df8e529f06d03a4a987878c/mypy-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29ea6da86c8c5e9addd48fa6e624f467341b3814f54ded871b28980468686dea", size = 14992675, upload-time = "2026-05-06T19:23:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6a/1cbd7290f00b4dbaa4c4502e53ac05645ea635e4d1e3dcd42687c2fc39cd/mypy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:904baa0124ebbccf0c7ba94f722cf9186ee30478f5e5b11432ffc8929248ee55", size = 10983628, upload-time = "2026-05-06T19:26:39.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/3f/8caa9bcc2636cd512642050747466b695fa2540d7040544fd7ddb721d671/mypy-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:440165501295e523bf1e5d3e411b62b367b901c65610938e75f0e56ba0462461", size = 9906041, upload-time = "2026-05-06T19:24:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, - { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, - { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, - { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, - { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/6fefe954207860aed6eeb91776795e64a257d3ce0360862288984ce121f5/mypy-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c918c64e8ce36557851b0347f84eb12f1965d3a06813c36df253eb0c0afd1d82", size = 14729633, upload-time = "2026-05-06T19:24:53.383Z" }, - { url = "https://files.pythonhosted.org/packages/23/d6/d336f5b820af189eb0390cce21de62d264c0a4e64713dfbe81bfc4fc7739/mypy-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:301f1a8ccc7d79b542ee218b28bb49443a83e194eb3d10da63ff1649e5aa5d34", size = 13559524, upload-time = "2026-05-06T19:22:24.906Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/d7bb54fde1770f0484e5fbdbdce37a41e95ed0a1cd493ec60ead111e356c/mypy-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf4ef489d44ce350bac3fd699907834e551d4c934e9cc862ef201215ab1558d", size = 13936018, upload-time = "2026-05-06T19:25:02.992Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ba/5be51316b91e6a6bf6e3a8adb3de500e7e1fb5bf9491743b8cbc81a34a2c/mypy-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cde2d0989f912fc850890f727d0d76495e7a6c5bdd9912a1efdb64952b4398d", size = 14910712, upload-time = "2026-05-06T19:25:21.83Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/e2c8c3b373e20ebfb66e6c83a99027fd67df4ec43b08879f74e822d2dc4c/mypy-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdf05693c231a14fe37dbfce192a3a1372c26a833af4a80f550547742952e719", size = 15141499, upload-time = "2026-05-06T19:20:50.924Z" }, - { url = "https://files.pythonhosted.org/packages/12/36/07756f933e00416d912e35878cfcf89a593a3350a885691c0bb85ae0226a/mypy-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:73aee2da33a2237e66cbe84a94780e53599847e86bb3aa7b93e405e8cd9905f2", size = 11240511, upload-time = "2026-05-06T19:21:32.39Z" }, - { url = "https://files.pythonhosted.org/packages/70/05/79ac1f20f2397353f3845f7b8bb5d8006cda7c8ef9092f04f9de3c6135f2/mypy-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:1f6dcd8f39971f41edab2728c877c4ac8b50ad3c387ff2770423b79a05d23910", size = 10149336, upload-time = "2026-05-06T19:22:08.383Z" }, - { url = "https://files.pythonhosted.org/packages/53/e0/0db84e0ebbad6e99e566c68e4b465784f2a2294f7719e8db9d509ef23087/mypy-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a04e980b9275c76159da66c6e1723c7798306f9802b31bdaf9358d0c84030ce8", size = 15797362, upload-time = "2026-05-06T19:22:00.835Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a4/14cc0768164dd53bec48aa41a20270b18df9bf72aa5054278bf133608315/mypy-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33f9cf4825469b2bc73c53ba55f6d9a9b4cdb60f9e6e228745581520f29b8771", size = 14635914, upload-time = "2026-05-06T19:23:43.675Z" }, - { url = "https://files.pythonhosted.org/packages/08/48/d866a3e23b4dc5974c77d9cf65a435bf22de01a84dd4620917950e233960/mypy-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191675c3c7dc2a5c7722a035a6909c277f14046c5e4e02aa5fbf65f8524f08ad", size = 15270866, upload-time = "2026-05-06T19:22:34.756Z" }, - { url = "https://files.pythonhosted.org/packages/71/eb/de9ef94958eb2078a6b908ceb247757dc384d3a238d3bd6ed7d81de5eaf8/mypy-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d26c4321a3b06fc9f04c741e0733af693f82d823f8e64e47b2e63b7f19fa84", size = 16093131, upload-time = "2026-05-06T19:23:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/ad/07/0ab2c1a9d26e90942612724cbd5788f16b7810c5dd39bfcf79286c6c4524/mypy-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bbcbc4d5917ca6ce12de70e051de7f533e3bf92d548b41a38a2232a6fe356525", size = 16330685, upload-time = "2026-05-06T19:21:42.037Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/46f85d1371a5be642dad263828118ae1efd536d91d8bd2000c68acff3920/mypy-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:dbc6ba6d40572ae49268531565793a8f07eac7fc65ad76d482c9b4c8765b6043", size = 12752017, upload-time = "2026-05-06T19:22:44.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e6/94ca48800cac19eb28a58188a768aaec0d16cac0f373915f073058ab0855/mypy-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:77926029dfcb7e1a3ecb0acb2ddbb24ca36be03f7d623e1759ad5376be8f6c01", size = 10527097, upload-time = "2026-05-06T19:20:58.973Z" }, - { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1011,7 +1027,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1019,125 +1035,125 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, - { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -1151,15 +1167,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -1362,7 +1378,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1370,34 +1386,34 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "ruff" -version = "0.15.12" +version = "0.15.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -1471,15 +1487,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, ] [[package]] From e0437d875a523073484d400da99261d2174067a1 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Sun, 24 May 2026 15:00:34 -0300 Subject: [PATCH 08/13] refactor: updates sqlalchemy orm and rename shared binding policy and validation --- .../{_policy.py => core/binding_policy.py} | 0 .../{_validation.py => core/validation.py} | 0 src/pyrsql/orms/base.py | 54 +------ src/pyrsql/orms/sqlalchemy/__init__.py | 2 +- src/pyrsql/orms/sqlalchemy/coercion.py | 64 ++++++-- .../{compiled.py => compiled_query.py} | 0 src/pyrsql/orms/sqlalchemy/introspection.py | 24 ++- src/pyrsql/orms/sqlalchemy/json_path.py | 116 +++++++------- src/pyrsql/orms/sqlalchemy/orm.py | 39 +++-- src/pyrsql/orms/sqlalchemy/resolver.py | 147 +++++++++++++----- src/pyrsql/orms/sqlalchemy/sorter.py | 40 +++-- src/pyrsql/orms/sqlalchemy/statement.py | 48 ++++-- src/pyrsql/orms/sqlalchemy/translator.py | 55 ++++--- src/pyrsql/orms/sqlalchemy/type_inference.py | 34 +++- src/pyrsql/orms/sqlalchemy/types.py | 11 +- src/pyrsql/parsing/limits.py | 2 +- src/pyrsql/semantic/binder.py | 2 +- src/pyrsql/sorting/binder.py | 2 +- src/pyrsql/sorting/limits.py | 2 +- .../sqlalchemy/test_query_pipeline.py | 49 ++++++ .../sqlalchemy/test_sort_pipeline.py | 27 ++++ tests/unit/orms/sqlalchemy/test_resolver.py | 39 +++++ tests/unit/orms/sqlalchemy/test_statement.py | 61 ++++++++ 23 files changed, 574 insertions(+), 244 deletions(-) rename src/pyrsql/{_policy.py => core/binding_policy.py} (100%) rename src/pyrsql/{_validation.py => core/validation.py} (100%) rename src/pyrsql/orms/sqlalchemy/{compiled.py => compiled_query.py} (100%) create mode 100644 tests/unit/orms/sqlalchemy/test_statement.py diff --git a/src/pyrsql/_policy.py b/src/pyrsql/core/binding_policy.py similarity index 100% rename from src/pyrsql/_policy.py rename to src/pyrsql/core/binding_policy.py diff --git a/src/pyrsql/_validation.py b/src/pyrsql/core/validation.py similarity index 100% rename from src/pyrsql/_validation.py rename to src/pyrsql/core/validation.py diff --git a/src/pyrsql/orms/base.py b/src/pyrsql/orms/base.py index 8259b79..a225d8e 100644 --- a/src/pyrsql/orms/base.py +++ b/src/pyrsql/orms/base.py @@ -3,60 +3,18 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Protocol, TypeVar +from typing import TYPE_CHECKING, TypeAlias + +from pyrsql.core.compiler import CompiledArtifact if TYPE_CHECKING: from pyrsql.core.page import PageRequest from pyrsql.core.query import Query from pyrsql.core.sort import Sort -_TargetT = TypeVar("_TargetT") -_ModelT = TypeVar("_ModelT") - - -class CompiledQuery(Protocol): - """Protocol implemented by ORM-specific compiled query objects.""" - - def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies a compiled query to an ORM-specific target. - - Args: - target: ORM-specific target to mutate. - model: ORM model class used to resolve the query. - - Returns: - The value returned by the compiled query application. - """ - - -class CompiledSort(Protocol): - """Protocol implemented by ORM-specific compiled sort objects.""" - - def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies a compiled sort to an ORM-specific target. - - Args: - target: ORM-specific target to mutate. - model: ORM model class used to resolve the sort. - - Returns: - The value returned by the compiled sort application. - """ - - -class CompiledPageRequest(Protocol): - """Protocol implemented by ORM-specific compiled page objects.""" - - def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: - """Applies a compiled page request to an ORM-specific target. - - Args: - target: ORM-specific target to mutate. - model: ORM model class used to resolve the page request. - - Returns: - The value returned by the compiled page application. - """ +CompiledQuery: TypeAlias = CompiledArtifact +CompiledSort: TypeAlias = CompiledArtifact +CompiledPageRequest: TypeAlias = CompiledArtifact class ORM(ABC): diff --git a/src/pyrsql/orms/sqlalchemy/__init__.py b/src/pyrsql/orms/sqlalchemy/__init__.py index a332fbf..57515ad 100644 --- a/src/pyrsql/orms/sqlalchemy/__init__.py +++ b/src/pyrsql/orms/sqlalchemy/__init__.py @@ -1,10 +1,10 @@ """SQLAlchemy ORM exports.""" from pyrsql.orms.sqlalchemy.coercion import SQLAlchemyValueCoercer -from pyrsql.orms.sqlalchemy.compiled import SQLAlchemyCompiledQuery from pyrsql.orms.sqlalchemy.compiled_page import ( SQLAlchemyCompiledPageRequest, ) +from pyrsql.orms.sqlalchemy.compiled_query import SQLAlchemyCompiledQuery from pyrsql.orms.sqlalchemy.compiled_sort import SQLAlchemyCompiledSort from pyrsql.orms.sqlalchemy.errors import ( SQLAlchemyJSONSupportError, diff --git a/src/pyrsql/orms/sqlalchemy/coercion.py b/src/pyrsql/orms/sqlalchemy/coercion.py index 7adf0cf..9378cd8 100644 --- a/src/pyrsql/orms/sqlalchemy/coercion.py +++ b/src/pyrsql/orms/sqlalchemy/coercion.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from pyrsql.core.conversion import ( FieldValueConverterSet, + ValueConverter, ValueConverterRegistry, ) @@ -44,25 +45,64 @@ def coerce( Returns: The coerced Python value. - - Raises: - SQLAlchemyORMError: If coercion fails or no registry is available. """ - field_label = field_path or field_name + field_converter = None if field_converter_set is not None: field_converter = field_converter_set.resolve( model=model, field_name=field_name, field_path=field_path, ) - if field_converter is not None: - try: - return field_converter(raw_value) - except Exception as error: - raise SQLAlchemyORMError( - f"Failed to convert {raw_value!r} for field " - f"{field_label!r}.", - ) from error + if field_converter is not None: + return self._coerce_with_field_converter( + raw_value, + field_converter, + field_label=field_path or field_name, + ) + return self._coerce_with_registry( + raw_value, + python_type, + registry=registry, + ) + + @staticmethod + def _coerce_with_field_converter( + raw_value: str, + field_converter: ValueConverter, + *, + field_label: str | None, + ) -> object: + """Coerces one value using a resolved field-specific converter. + + Returns: + The coerced Python value. + + Raises: + SQLAlchemyORMError: If the field-specific conversion fails. + """ + try: + return field_converter(raw_value) + except Exception as error: + raise SQLAlchemyORMError( + f"Failed to convert {raw_value!r} for field {field_label!r}.", + ) from error + + def _coerce_with_registry( + self, + raw_value: str, + python_type: type[object] | None, + *, + registry: ValueConverterRegistry | None, + ) -> object: + """Coerces one value using the active conversion registry. + + Returns: + The coerced Python value. + + Raises: + SQLAlchemyORMError: If no registry is available or conversion + fails. + """ active_registry = registry or self._registry if active_registry is None: raise SQLAlchemyORMError( diff --git a/src/pyrsql/orms/sqlalchemy/compiled.py b/src/pyrsql/orms/sqlalchemy/compiled_query.py similarity index 100% rename from src/pyrsql/orms/sqlalchemy/compiled.py rename to src/pyrsql/orms/sqlalchemy/compiled_query.py diff --git a/src/pyrsql/orms/sqlalchemy/introspection.py b/src/pyrsql/orms/sqlalchemy/introspection.py index fa8f046..e49d295 100644 --- a/src/pyrsql/orms/sqlalchemy/introspection.py +++ b/src/pyrsql/orms/sqlalchemy/introspection.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from sqlalchemy.orm.properties import ColumnProperty + from sqlalchemy.sql.schema import Column class SQLAlchemyModelInspector: @@ -153,7 +154,17 @@ def _cache_mapped_attribute( return mapped_attribute @staticmethod + def _first_column( + column_property: ColumnProperty[Any], + ) -> Column[Any] | None: + """Returns the first mapped column for a column property, if any.""" + if not column_property.columns: + return None + return column_property.columns[0] + + @classmethod def _resolve_column_python_type( + cls, column_property: ColumnProperty[Any], ) -> type[Any] | None: """Returns the Python type for a column property when available. @@ -161,15 +172,17 @@ def _resolve_column_python_type( Returns: The column Python type, or ``None`` when unavailable. """ - if not column_property.columns: + column = cls._first_column(column_property) + if column is None: return None try: - return column_property.columns[0].type.python_type + return column.type.python_type except (AttributeError, NotImplementedError): return None - @staticmethod + @classmethod def _is_json_column( + cls, column_property: ColumnProperty[Any], ) -> bool: """Returns whether the column property stores JSON-like data. @@ -177,6 +190,7 @@ def _is_json_column( Returns: ``True`` when the column property stores JSON-like data. """ - if not column_property.columns: + column = cls._first_column(column_property) + if column is None: return False - return isinstance(column_property.columns[0].type, JSON) + return isinstance(column.type, JSON) diff --git a/src/pyrsql/orms/sqlalchemy/json_path.py b/src/pyrsql/orms/sqlalchemy/json_path.py index b2dd752..ea0806f 100644 --- a/src/pyrsql/orms/sqlalchemy/json_path.py +++ b/src/pyrsql/orms/sqlalchemy/json_path.py @@ -61,6 +61,28 @@ ) _ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") _ISO_TIME_PATTERN = re.compile(r"^\d{2}:\d{2}:\d{2}(\.\d+)?$") +_JSON_ENCODER = msgspec.json.Encoder() +_ORDERED_COMPARISON_OPERATORS = MappingProxyType( + { + GREATER_THAN.name: ">", + GREATER_THAN_OR_EQUAL.name: ">=", + LESS_THAN.name: "<", + LESS_THAN_OR_EQUAL.name: "<=", + }, +) +_IGNORE_CASE_JSON_OPERATORS = frozenset( + { + IGNORE_CASE.name, + IGNORE_CASE_LIKE.name, + IGNORE_CASE_NOT_LIKE.name, + }, +) +_NEGATED_REGEX_JSON_OPERATORS = frozenset( + { + NOT_LIKE.name, + IGNORE_CASE_NOT_LIKE.name, + }, +) class SQLAlchemyJSONPathExpressionBuilder: @@ -158,9 +180,10 @@ def build_filter_expression( Returns: A PostgreSQL ``jsonb_path_exists`` predicate. """ + active_options = options or DEFAULT_JSON_OPTIONS function_call = self._build_filter_call( comparison, - options=options or DEFAULT_JSON_OPTIONS, + options=active_options, ) jsonb_column = cast( "ColumnElement[Any]", @@ -170,34 +193,17 @@ def build_filter_expression( function_call.json_path_expression, ) vars_payload = self._jsonpath_vars_payload(function_call.vars_payload) - if function_call.use_timezone_function: - function_expression = getattr( - sa.func, - function_call.path_exists_tz_function, - )( - jsonb_column, - json_path_literal, - vars_payload, - ) - return cast("ColumnElement[bool]", function_expression) - if options and options.path_exists_function != "jsonb_path_exists": - function_expression = getattr( - sa.func, - options.path_exists_function, - )( - jsonb_column, - json_path_literal, - vars_payload, - ) - return cast("ColumnElement[bool]", function_expression) - return cast( - "ColumnElement[bool]", - sa.func.jsonb_path_exists( - jsonb_column, - json_path_literal, - vars_payload, - ), + function_name = ( + function_call.path_exists_tz_function + if function_call.use_timezone_function + else active_options.path_exists_function + ) + function_expression = getattr(sa.func, function_name)( + jsonb_column, + json_path_literal, + vars_payload, ) + return cast("ColumnElement[bool]", function_expression) @staticmethod def _jsonpath_literal(expression: str) -> ColumnElement[Any]: @@ -380,16 +386,20 @@ def _build_filter_call( """ target_path = comparison.path.to_postgresql_jsonpath() use_datetime = options.use_datetime - use_timezone_function = use_datetime and any( - self._is_timezone_datetime_value(value) - for value in comparison.values - ) + has_datetime_value = False + use_timezone_function = False + if use_datetime: + for value in comparison.values: + if self._is_timezone_datetime_value(value): + has_datetime_value = True + use_timezone_function = True + break + if self._is_datetime_value(value): + has_datetime_value = True vars_payload: dict[str, Any] = {} - value_reference = "@" - if use_datetime and any( - self._is_datetime_value(value) for value in comparison.values - ): - value_reference = "@.datetime()" + value_reference = ( + "@.datetime()" if use_datetime and has_datetime_value else "@" + ) match comparison.operator_name: case NOT_NULL.name: comparison_clause = "(@ != null)" @@ -414,12 +424,9 @@ def _build_filter_call( | LESS_THAN.name | LESS_THAN_OR_EQUAL.name ): - operator = { - GREATER_THAN.name: ">", - GREATER_THAN_OR_EQUAL.name: ">=", - LESS_THAN.name: "<", - LESS_THAN_OR_EQUAL.name: "<=", - }[comparison.operator_name] + operator = _ORDERED_COMPARISON_OPERATORS[ + comparison.operator_name + ] value_literal = self._render_value_operand( comparison.values[0], variable_name="value_0", @@ -456,19 +463,14 @@ def _build_filter_call( | IGNORE_CASE_LIKE.name | IGNORE_CASE_NOT_LIKE.name ): - ignore_case = comparison.operator_name in { - IGNORE_CASE.name, - IGNORE_CASE_LIKE.name, - IGNORE_CASE_NOT_LIKE.name, - } + ignore_case = ( + comparison.operator_name in _IGNORE_CASE_JSON_OPERATORS + ) comparison_clause = self._regex_comparison( comparison.values[0], ignore_case=ignore_case, ) - if comparison.operator_name in { - NOT_LIKE.name, - IGNORE_CASE_NOT_LIKE.name, - }: + if comparison.operator_name in _NEGATED_REGEX_JSON_OPERATORS: comparison_clause = f"!{comparison_clause}" case BETWEEN.name | NOT_BETWEEN.name: lower_literal = self._render_value_operand( @@ -554,7 +556,7 @@ def _regex_comparison( if "*" not in normalized: normalized = f"*{normalized}*" pattern = re.escape(normalized).replace(r"\*", ".*") - literal = msgspec.json.encode(pattern).decode() + literal = _JSON_ENCODER.encode(pattern).decode() if ignore_case: return f'(@ like_regex {literal} flag "i")' return f"(@ like_regex {literal})" @@ -649,11 +651,3 @@ class _JSONPathFilterCall( use_timezone_function: bool path_exists_tz_function: str vars_payload: Mapping[str, Any] - - def __post_init__(self) -> None: - """Normalizes vars payload into an immutable mapping.""" - msgspec.structs.force_setattr( - self, - "vars_payload", - MappingProxyType(dict(self.vars_payload)), - ) diff --git a/src/pyrsql/orms/sqlalchemy/orm.py b/src/pyrsql/orms/sqlalchemy/orm.py index dcb878b..44c0e7e 100644 --- a/src/pyrsql/orms/sqlalchemy/orm.py +++ b/src/pyrsql/orms/sqlalchemy/orm.py @@ -7,8 +7,8 @@ from typing_extensions import override from pyrsql.orms import base -from pyrsql.orms.sqlalchemy.compiled import SQLAlchemyCompiledQuery from pyrsql.orms.sqlalchemy.compiled_page import SQLAlchemyCompiledPageRequest +from pyrsql.orms.sqlalchemy.compiled_query import SQLAlchemyCompiledQuery from pyrsql.orms.sqlalchemy.compiled_sort import SQLAlchemyCompiledSort from pyrsql.orms.sqlalchemy.sorter import SQLAlchemySortTranslator from pyrsql.orms.sqlalchemy.translator import SQLAlchemyExpressionTranslator @@ -36,21 +36,38 @@ def __init__( Mapping[str, SQLAlchemyCustomPredicate] | None ) = None, ) -> None: - """Creates a SQLAlchemy ORM adapter. + """Creates a SQLAlchemy ORM adapter.""" + self._translator = self._build_translator( + translator=translator, + custom_predicates=custom_predicates, + ) + self._sort_translator = sort_translator or SQLAlchemySortTranslator() + + @staticmethod + def _build_translator( + *, + translator: SQLAlchemyExpressionTranslator | None, + custom_predicates: (Mapping[str, SQLAlchemyCustomPredicate] | None), + ) -> SQLAlchemyExpressionTranslator: + """Builds the effective query translator. + + Returns: + The translator to use for query compilation. Raises: - ValueError: If custom predicates are provided together with an - explicit translator. + ValueError: If custom predicates are combined with an explicit + translator. """ - if translator is not None and custom_predicates is not None: - raise ValueError( - "custom_predicates cannot be provided when translator is " - "passed explicitly.", - ) - self._translator = translator or SQLAlchemyExpressionTranslator( + if translator is not None: + if custom_predicates is not None: + raise ValueError( + "custom_predicates cannot be provided when translator is " + "passed explicitly.", + ) + return translator + return SQLAlchemyExpressionTranslator( custom_predicates=custom_predicates, ) - self._sort_translator = sort_translator or SQLAlchemySortTranslator() @property def name(self) -> str: diff --git a/src/pyrsql/orms/sqlalchemy/resolver.py b/src/pyrsql/orms/sqlalchemy/resolver.py index 8d8895f..66d0ee5 100644 --- a/src/pyrsql/orms/sqlalchemy/resolver.py +++ b/src/pyrsql/orms/sqlalchemy/resolver.py @@ -27,6 +27,7 @@ ) _MAX_FIELD_MAPPING_EXPANSIONS: Final = 32 +_ROOT_JSON_PATH: Final = JSONPath() class SQLAlchemyPathResolver: @@ -106,39 +107,30 @@ def _resolve_with_field_policy( raise SQLAlchemyPathResolutionError( f"Field path {field_path!r} is invalid.", ) - segments = raw_segments - + if field_policy is not None: + self._validate_global_field_policy(field_policy, field_path) current_model = model joins: list[SQLAlchemyJoinPlan] = [] leaf_attribute: SQLAlchemyMappedAttribute | None = None - segments_to_resolve = list(segments) + segments_to_resolve = list(raw_segments) segment_index = 0 expansion_count = 0 while segment_index < len(segments_to_resolve): segment = segments_to_resolve[segment_index] - if field_policy is not None: - mapped_segment = field_policy.map_model_field( - current_model, - segment, - ) - if mapped_segment != segment: - mapped_segments = tuple(mapped_segment.split(".")) - if any(not part for part in mapped_segments): - raise SQLAlchemyPathResolutionError( - f"Mapped field {segment!r} on " - f"{current_model.__name__!r} is invalid.", - ) - segments_to_resolve[segment_index : segment_index + 1] = ( - mapped_segments + if field_policy is not None and self._expand_mapped_segments( + field_policy, + current_model, + segment, + segments_to_resolve, + segment_index, + ): + expansion_count += 1 + if expansion_count > _MAX_FIELD_MAPPING_EXPANSIONS: + raise SQLAlchemyPathResolutionError( + "Field mapping expansion exceeded the supported limit.", ) - expansion_count += 1 - if expansion_count > _MAX_FIELD_MAPPING_EXPANSIONS: - raise SQLAlchemyPathResolutionError( - "Field mapping expansion exceeded the supported " - "limit.", - ) - continue + continue mapped_attribute = self._inspector.get_mapped_attribute( current_model, segment, @@ -170,15 +162,18 @@ def _resolve_with_field_policy( case SQLAlchemyAttributeKind.COLUMN: if not is_last_segment: if mapped_attribute.is_json: - return SQLAlchemyResolvedPath( + if field_policy is not None: + self._validate_model_field_policy( + field_policy, + current_model, + mapped_attribute.name, + ) + return self._build_resolved_path( root_model=model, leaf_model=current_model, - field_path=".".join(segments_to_resolve), - joins=tuple(joins), - leaf_attribute=cast( - "ColumnElement[Any]", - mapped_attribute.attribute, - ), + field_path=field_path, + joins=joins, + leaf_attribute=mapped_attribute, python_type=None, json_path=JSONPath( segments=tuple( @@ -195,11 +190,10 @@ def _resolve_with_field_policy( ) leaf_attribute = mapped_attribute if field_policy is not None: - self._validate_field_policy( + self._validate_model_field_policy( field_policy, current_model, leaf_attribute.name, - ".".join(segments_to_resolve), ) segment_index += 1 case _: @@ -212,18 +206,72 @@ def _resolve_with_field_policy( raise SQLAlchemyORMError( "Path resolution ended without a resolved leaf attribute.", ) - return SQLAlchemyResolvedPath( + return self._build_resolved_path( root_model=model, leaf_model=current_model, field_path=field_path, + joins=joins, + leaf_attribute=leaf_attribute, + python_type=leaf_attribute.python_type, + is_json=leaf_attribute.is_json, + ) + + @staticmethod + def _expand_mapped_segments( + field_policy: FieldPolicySet, + model: type[Any], + segment: str, + segments_to_resolve: list[str], + segment_index: int, + ) -> bool: + """Expands one model-scoped field mapping in place when configured. + + Returns: + ``True`` when the current segment was replaced by mapped segments. + + Raises: + SQLAlchemyPathResolutionError: If the mapped segments are invalid. + """ + mapped_segment = field_policy.map_model_field(model, segment) + if mapped_segment == segment: + return False + mapped_segments = tuple(mapped_segment.split(".")) + if any(not part for part in mapped_segments): + raise SQLAlchemyPathResolutionError( + f"Mapped field {segment!r} on {model.__name__!r} is invalid.", + ) + segments_to_resolve[segment_index : segment_index + 1] = mapped_segments + return True + + @staticmethod + def _build_resolved_path( + *, + root_model: type[Any], + leaf_model: type[Any], + field_path: str, + joins: list[SQLAlchemyJoinPlan], + leaf_attribute: SQLAlchemyMappedAttribute, + python_type: type[Any] | None, + json_path: JSONPath | None = None, + is_json: bool = False, + ) -> SQLAlchemyResolvedPath: + """Builds one immutable resolved path payload. + + Returns: + The immutable resolved path payload. + """ + return SQLAlchemyResolvedPath( + root_model=root_model, + leaf_model=leaf_model, + field_path=field_path, joins=tuple(joins), leaf_attribute=cast( "ColumnElement[Any]", leaf_attribute.attribute, ), - python_type=leaf_attribute.python_type, - json_path=JSONPath(), - is_json=leaf_attribute.is_json, + python_type=python_type, + json_path=json_path if json_path is not None else _ROOT_JSON_PATH, + is_json=is_json, ) @staticmethod @@ -239,19 +287,34 @@ def _make_join_key( return f"{model.__name__}.{segment}" @staticmethod - def _validate_field_policy( + def _validate_global_field_policy( field_policy: FieldPolicySet, - model: type[Any], - field_name: str, field_path: str, ) -> None: - """Validates field access using global and model-specific rules. + """Validates global field access rules for one requested path. Raises: - SQLAlchemyPathResolutionError: If the field access is invalid. + SQLAlchemyPathResolutionError: If the field path is globally + blocked or not allowed. """ try: field_policy.validate_global_field_access(field_path) + except ValueError as error: + raise SQLAlchemyPathResolutionError(str(error)) from error + + @staticmethod + def _validate_model_field_policy( + field_policy: FieldPolicySet, + model: type[Any], + field_name: str, + ) -> None: + """Validates model-specific field access rules for one leaf field. + + Raises: + SQLAlchemyPathResolutionError: If the leaf field is blocked or not + allowed for the model. + """ + try: field_policy.validate_model_field_access(model, field_name) except ValueError as error: raise SQLAlchemyPathResolutionError(str(error)) from error diff --git a/src/pyrsql/orms/sqlalchemy/sorter.py b/src/pyrsql/orms/sqlalchemy/sorter.py index 4b49fa0..0516944 100644 --- a/src/pyrsql/orms/sqlalchemy/sorter.py +++ b/src/pyrsql/orms/sqlalchemy/sorter.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast import sqlalchemy as sa @@ -36,6 +37,17 @@ ) +_JSON_SORT_PYTHON_TYPES = MappingProxyType( + { + JSONSortScalarType.TEXT: str, + JSONSortScalarType.INTEGER: int, + JSONSortScalarType.FLOAT: float, + JSONSortScalarType.NUMERIC: float, + JSONSortScalarType.BOOLEAN: bool, + }, +) + + class SQLAlchemySortTranslator: """Lowers bound sort IR to SQLAlchemy order clauses.""" @@ -152,13 +164,19 @@ def _translate_selector( sa.func, selector.function_name, )(*argument_expressions) + function_expression = cast( + "ColumnElement[Any]", + function_expression, + ) + python_type = infer_sql_function_python_type( + selector.function_name, + tuple(argument_types), + function_expression=function_expression, + ) return ( tuple(joins), - cast("ColumnElement[Any]", function_expression), - infer_sql_function_python_type( - selector.function_name, - tuple(argument_types), - ), + function_expression, + python_type, ) def _resolve_column_expression( @@ -198,17 +216,7 @@ def _resolve_json_python_type( field_path, JSONSortScalarType.TEXT, ) - match sort_type: - case JSONSortScalarType.TEXT: - return str - case JSONSortScalarType.INTEGER: - return int - case JSONSortScalarType.FLOAT | JSONSortScalarType.NUMERIC: - return float - case JSONSortScalarType.BOOLEAN: - return bool - case _: - return None + return _JSON_SORT_PYTHON_TYPES.get(sort_type) @staticmethod def _build_order_clause( diff --git a/src/pyrsql/orms/sqlalchemy/statement.py b/src/pyrsql/orms/sqlalchemy/statement.py index ac70998..d76ac46 100644 --- a/src/pyrsql/orms/sqlalchemy/statement.py +++ b/src/pyrsql/orms/sqlalchemy/statement.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import MappingProxyType from typing import TYPE_CHECKING from sqlalchemy.sql import Select @@ -17,6 +18,8 @@ SQLAlchemySelect, ) +_EMPTY_JOIN_HINTS = MappingProxyType({}) + def require_sqlalchemy_select(target: object) -> SQLAlchemySelect: """Validates and narrows an ORM target to a SQLAlchemy Select. @@ -43,21 +46,40 @@ def apply_relationship_joins( Returns: The statement with the requested joins applied. - Raises: - SQLAlchemyORMError: If a join hint requests an unsupported join type. """ - join_hints = join_hints or {} - deduplicated_joins = dict.fromkeys(joins) + if not joins: + return statement + join_hints = join_hints if join_hints is not None else _EMPTY_JOIN_HINTS + deduplicated_joins = joins if len(joins) == 1 else dict.fromkeys(joins) updated_statement = statement for join_plan in deduplicated_joins: - resolved_hint = join_hints.get(join_plan.key, join_plan.default_hint) - if resolved_hint is JoinHint.INNER: - updated_statement = updated_statement.join(join_plan.attribute) - continue - if resolved_hint is JoinHint.LEFT: - updated_statement = updated_statement.outerjoin(join_plan.attribute) - continue - raise SQLAlchemyORMError( - "SQLAlchemy ORM does not support RIGHT joins via join_hints.", + updated_statement = _apply_join_plan( + updated_statement, + join_plan, + join_hints=join_hints, ) return updated_statement + + +def _apply_join_plan( + statement: SQLAlchemySelect, + join_plan: SQLAlchemyJoinPlan, + *, + join_hints: Mapping[str, JoinHint], +) -> SQLAlchemySelect: + """Applies one relationship join according to the resolved join hint. + + Returns: + The statement with one join applied. + + Raises: + SQLAlchemyORMError: If a join hint requests an unsupported join type. + """ + resolved_hint = join_hints.get(join_plan.key, join_plan.default_hint) + if resolved_hint is JoinHint.INNER: + return statement.join(join_plan.attribute) + if resolved_hint is JoinHint.LEFT: + return statement.outerjoin(join_plan.attribute) + raise SQLAlchemyORMError( + "SQLAlchemy ORM does not support RIGHT joins via join_hints.", + ) diff --git a/src/pyrsql/orms/sqlalchemy/translator.py b/src/pyrsql/orms/sqlalchemy/translator.py index fea1daa..1bb5ead 100644 --- a/src/pyrsql/orms/sqlalchemy/translator.py +++ b/src/pyrsql/orms/sqlalchemy/translator.py @@ -66,6 +66,19 @@ SQLAlchemyResolvedPath, ) +_IGNORE_CASE_LIKE_OPERATORS = frozenset( + { + IGNORE_CASE_LIKE.name, + IGNORE_CASE_NOT_LIKE.name, + }, +) +_NEGATED_LIKE_OPERATORS = frozenset( + { + NOT_LIKE.name, + IGNORE_CASE_NOT_LIKE.name, + }, +) + class SQLAlchemyExpressionTranslator: """Lowers bound query IR to SQLAlchemy predicates. @@ -315,13 +328,19 @@ def _translate_selector( sa.func, selector.function_name, )(*argument_expressions) + function_expression = cast( + "ColumnElement[Any]", + function_expression, + ) + python_type = infer_sql_function_python_type( + selector.function_name, + tuple(argument_types), + function_expression=function_expression, + ) return ( tuple(joins), - cast("ColumnElement[Any]", function_expression), - infer_sql_function_python_type( - selector.function_name, - tuple(argument_types), - ), + function_expression, + python_type, ) def _resolve_column_expression( @@ -414,16 +433,8 @@ def _build_predicate( return self._build_contains_predicate( expression, str(values[0]), - ignore_case=operator_name - in { - IGNORE_CASE_LIKE.name, - IGNORE_CASE_NOT_LIKE.name, - }, - negated=operator_name - in { - NOT_LIKE.name, - IGNORE_CASE_NOT_LIKE.name, - }, + ignore_case=operator_name in _IGNORE_CASE_LIKE_OPERATORS, + negated=operator_name in _NEGATED_LIKE_OPERATORS, options=options, ) case IGNORE_CASE.name: @@ -489,8 +500,8 @@ def _build_string_equality_predicate( if options.strict_equality: return expression == value - ignore_case = "^" in value - normalized_value = value.replace("^", "") + ignore_case = value.startswith("^") + normalized_value = value[1:] if ignore_case else value if "*" in normalized_value: pattern = normalized_value.replace("*", "%") return self._build_pattern_predicate( @@ -594,9 +605,13 @@ def _should_use_exists_predicate( Returns: ``True`` when the filter should use EXISTS wrapping. """ - if options.join_hints: - return False - return any(join_plan.is_collection for join_plan in joins) + has_collection_join = False + for join_plan in joins: + if join_plan.key in options.join_hints: + return False + if join_plan.is_collection: + has_collection_join = True + return has_collection_join def _finalize_predicate( self, diff --git a/src/pyrsql/orms/sqlalchemy/type_inference.py b/src/pyrsql/orms/sqlalchemy/type_inference.py index 3681282..2322c80 100644 --- a/src/pyrsql/orms/sqlalchemy/type_inference.py +++ b/src/pyrsql/orms/sqlalchemy/type_inference.py @@ -1,22 +1,46 @@ """Shared Python-type inference helpers for SQLAlchemy ORM translation.""" -from typing import Any +from __future__ import annotations -_STRING_SQL_FUNCTIONS = frozenset({"lower", "upper", "concat"}) +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sqlalchemy.sql.elements import ColumnElement + +_KNOWN_FUNCTION_RETURN_TYPES = MappingProxyType( + { + "lower": str, + "upper": str, + "concat": str, + }, +) def infer_sql_function_python_type( function_name: str, argument_types: tuple[type[Any] | None, ...], + *, + function_expression: ColumnElement[Any] | None = None, ) -> type[Any] | None: - """Infers the Python type for common SQL functions. + """Infers the Python type for one SQL function expression. + + This helper first asks SQLAlchemy for the concrete expression type when an + expression is available, then falls back to cheap, explicit heuristics for + a small set of well-known functions. Returns: The inferred Python type, or ``None`` when unknown. """ + if function_expression is not None: + try: + return function_expression.type.python_type + except (AttributeError, NotImplementedError): + pass normalized_name = function_name.lower() - if normalized_name in _STRING_SQL_FUNCTIONS: - return str + known_return_type = _KNOWN_FUNCTION_RETURN_TYPES.get(normalized_name) + if known_return_type is not None: + return known_return_type if normalized_name == "coalesce": for argument_type in argument_types: if argument_type is not None: diff --git a/src/pyrsql/orms/sqlalchemy/types.py b/src/pyrsql/orms/sqlalchemy/types.py index 3979acb..3b454af 100644 --- a/src/pyrsql/orms/sqlalchemy/types.py +++ b/src/pyrsql/orms/sqlalchemy/types.py @@ -6,20 +6,19 @@ from typing import TYPE_CHECKING, Any, TypeAlias import msgspec -from sqlalchemy.orm import Mapper -from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm import Mapper, QueryableAttribute from sqlalchemy.sql import Select -from sqlalchemy.sql.elements import ColumnElement from pyrsql.core.json.path import JSONPath if TYPE_CHECKING: + from sqlalchemy.sql.elements import ColumnElement + from pyrsql.core.joins import JoinHint SQLAlchemyModel: TypeAlias = type[Any] SQLAlchemySelect: TypeAlias = Select[Any] -SQLAlchemyExpression: TypeAlias = ColumnElement[Any] -SQLAlchemyAttribute: TypeAlias = InstrumentedAttribute[Any] +SQLAlchemyAttribute: TypeAlias = QueryableAttribute[Any] SQLAlchemyMapper: TypeAlias = Mapper[Any] @@ -56,7 +55,7 @@ class SQLAlchemyResolvedPath( leaf_model: SQLAlchemyModel field_path: str joins: tuple[SQLAlchemyJoinPlan, ...] - leaf_attribute: SQLAlchemyExpression + leaf_attribute: ColumnElement[Any] python_type: type[Any] | None json_path: JSONPath = msgspec.field(default_factory=JSONPath) is_json: bool = False diff --git a/src/pyrsql/parsing/limits.py b/src/pyrsql/parsing/limits.py index fa0c506..2e72c60 100644 --- a/src/pyrsql/parsing/limits.py +++ b/src/pyrsql/parsing/limits.py @@ -4,7 +4,7 @@ import msgspec -from pyrsql._validation import validate_positive_int +from pyrsql.core.validation import validate_positive_int class ParseLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): diff --git a/src/pyrsql/semantic/binder.py b/src/pyrsql/semantic/binder.py index a7beb26..d8ec081 100644 --- a/src/pyrsql/semantic/binder.py +++ b/src/pyrsql/semantic/binder.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from pyrsql._policy import ( +from pyrsql.core.binding_policy import ( MappedFieldBindingOptions, enforce_field_access_policy, enforce_function_access_policy, diff --git a/src/pyrsql/sorting/binder.py b/src/pyrsql/sorting/binder.py index 2315a12..f60d941 100644 --- a/src/pyrsql/sorting/binder.py +++ b/src/pyrsql/sorting/binder.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from pyrsql._policy import ( +from pyrsql.core.binding_policy import ( MappedFieldBindingOptions, enforce_field_access_policy, enforce_function_access_policy, diff --git a/src/pyrsql/sorting/limits.py b/src/pyrsql/sorting/limits.py index afa0a57..712a7e9 100644 --- a/src/pyrsql/sorting/limits.py +++ b/src/pyrsql/sorting/limits.py @@ -4,7 +4,7 @@ import msgspec -from pyrsql._validation import validate_positive_int +from pyrsql.core.validation import validate_positive_int class SortLimits(msgspec.Struct, frozen=True, gc=False, kw_only=True): diff --git a/tests/integration/sqlalchemy/test_query_pipeline.py b/tests/integration/sqlalchemy/test_query_pipeline.py index 3c25540..9a9173f 100644 --- a/tests/integration/sqlalchemy/test_query_pipeline.py +++ b/tests/integration/sqlalchemy/test_query_pipeline.py @@ -9,6 +9,8 @@ pytest.importorskip("sqlalchemy") from sqlalchemy import func, select +from sqlalchemy.sql.functions import GenericFunction +from sqlalchemy.types import Integer import pyrsql from pyrsql.core.custom import CustomPredicateDefinition @@ -28,6 +30,14 @@ ) +class TypedLength(GenericFunction[int]): + """Typed SQL function used to validate SQLAlchemy-first inference.""" + + name = "typed_length" + type = Integer() + inherit_cache = True + + def test_orm_applies_simple_where_clause(orm) -> None: """Applies a simple comparison to a Select.""" query = pyrsql.parse("name==demo") @@ -56,6 +66,22 @@ def test_orm_uses_exists_for_collection_relationship_filter(orm) -> None: assert "JOIN address" not in sql +def test_orm_keeps_exists_for_collection_filter_with_unrelated_join_hint( + orm, +) -> None: + """Unrelated join hints do not disable EXISTS collection semantics.""" + query = pyrsql.parse( + "addresses.city==belem", + options=QueryOptions( + join_hints={"User.company": JoinHint.LEFT}, + ), + ) + statement = orm.compile_query(query).apply(select(User), User) + sql = render_sql(statement) + assert "EXISTS" in sql + assert "JOIN address" not in sql + + def test_orm_applies_like_operator(orm) -> None: """Applies contains-like semantics for LIKE.""" query = pyrsql.parse("name=like=dem") @@ -110,6 +136,16 @@ def test_orm_applies_case_insensitive_equality_marker(orm) -> None: assert "=" in sql +def test_orm_keeps_literal_caret_inside_string_equality(orm) -> None: + """Treats '^' as a case-insensitive marker only when used as a prefix.""" + query = pyrsql.parse("name=='de^mo'") + statement = orm.compile_query(query).apply(select(User), User) + sql = render_sql(statement) + compiled = statement.compile() + assert "lower(" not in sql.lower() + assert compiled.params["name_1"] == "de^mo" + + def test_orm_uses_escape_character_for_like_queries(orm) -> None: """Propagates the configured escape character to LIKE expressions.""" query = pyrsql.parse( @@ -132,6 +168,19 @@ def test_orm_applies_function_selector_where_clause(orm) -> None: assert "upper(user_account.name) =" in sql +def test_orm_uses_sqlalchemy_function_return_type_for_argument_coercion( + orm, +) -> None: + """Uses SQLAlchemy function typing before heuristic fallback.""" + query = pyrsql.parse( + "@typed_length[name]==4", + options=QueryOptions(procedure_whitelist=("typed_length",)), + ) + statement = orm.compile_query(query).apply(select(User), User) + compiled = statement.compile() + assert compiled.params["typed_length_1"] == 4 + + def test_orm_applies_distinct_option(orm) -> None: """Applies SELECT DISTINCT when query options request it.""" query = pyrsql.parse( diff --git a/tests/integration/sqlalchemy/test_sort_pipeline.py b/tests/integration/sqlalchemy/test_sort_pipeline.py index 16243b1..54e3af7 100644 --- a/tests/integration/sqlalchemy/test_sort_pipeline.py +++ b/tests/integration/sqlalchemy/test_sort_pipeline.py @@ -7,6 +7,8 @@ pytest.importorskip("sqlalchemy") from sqlalchemy import select +from sqlalchemy.sql.functions import GenericFunction +from sqlalchemy.types import Integer from pyrsql.core.joins import JoinHint from pyrsql.core.json.options import JSONOptions, JSONSortScalarType @@ -17,6 +19,14 @@ from .conftest import Company, JsonEvent, User, render_sql +class TypedLength(GenericFunction[int]): + """Typed SQL function used to validate SQLAlchemy-first sort inference.""" + + name = "typed_length_sort" + type = Integer() + inherit_cache = True + + def test_orm_applies_simple_order_by_clause(orm) -> None: """Applies a simple ascending ORDER BY to a Select.""" statement = Sort.parse("name").apply( @@ -87,6 +97,23 @@ def test_orm_applies_function_selector_sort(orm) -> None: assert "upper(user_account.name) ASC" in sql +def test_orm_keeps_ignore_case_off_for_typed_non_string_function_sort( + orm, +) -> None: + """Does not wrap typed non-string function sorts in lower().""" + statement = Sort.parse( + "@typed_length_sort[name],asc,ic", + options=SortOptions(procedure_whitelist=("typed_length_sort",)), + ).apply( + select(User), + User, + orm=orm, + ) + sql = render_sql(statement) + assert "typed_length_sort(" in sql + assert "lower(" not in sql.lower() + + def test_orm_applies_left_join_hint_in_sort(orm) -> None: """Applies LEFT OUTER JOIN when requested by sort options.""" statement = Sort.parse( diff --git a/tests/unit/orms/sqlalchemy/test_resolver.py b/tests/unit/orms/sqlalchemy/test_resolver.py index 001d690..d16b708 100644 --- a/tests/unit/orms/sqlalchemy/test_resolver.py +++ b/tests/unit/orms/sqlalchemy/test_resolver.py @@ -273,6 +273,45 @@ def test_path_resolver_applies_model_field_mapping( assert resolved.leaf_model is Company +def test_path_resolver_preserves_requested_json_field_path_after_mapping( + path_resolver: SQLAlchemyPathResolver, +) -> None: + """Keeps the user-facing path in the resolved JSON payload.""" + resolved = path_resolver.resolve( + Event, + "meta.user.id", + field_policy=FieldPolicySet( + field_whitelist=frozenset(), + field_blacklist=frozenset(), + model_field_mapping={Event: {"meta": "payload"}}, + model_field_whitelist={}, + model_field_blacklist={}, + ), + ) + + assert resolved.field_path == "meta.user.id" + assert resolved.json_path == JSONPath(segments=("user", "id")) + assert resolved.is_json is True + + +def test_path_resolver_enforces_global_field_whitelist_on_json_paths( + path_resolver: SQLAlchemyPathResolver, +) -> None: + """Applies global field policies when resolving nested JSON paths.""" + with pytest.raises(SQLAlchemyPathResolutionError, match=r"not allowed"): + path_resolver.resolve( + Event, + "payload.user.id", + field_policy=FieldPolicySet( + field_whitelist=frozenset({"payload"}), + field_blacklist=frozenset(), + model_field_mapping={}, + model_field_whitelist={}, + model_field_blacklist={}, + ), + ) + + def test_path_resolver_enforces_model_field_whitelist( path_resolver: SQLAlchemyPathResolver, ) -> None: diff --git a/tests/unit/orms/sqlalchemy/test_statement.py b/tests/unit/orms/sqlalchemy/test_statement.py new file mode 100644 index 0000000..d4e7d88 --- /dev/null +++ b/tests/unit/orms/sqlalchemy/test_statement.py @@ -0,0 +1,61 @@ +"""Unit tests for SQLAlchemy statement helpers.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select + +from pyrsql.core.joins import JoinHint +from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError +from pyrsql.orms.sqlalchemy.statement import ( + apply_relationship_joins, + require_sqlalchemy_select, +) +from pyrsql.orms.sqlalchemy.types import SQLAlchemyJoinPlan + +from .conftest import User + +pytestmark = pytest.mark.sqlalchemy + + +def test_require_sqlalchemy_select_rejects_non_select_target() -> None: + """Rejects non-Select targets with a clear error.""" + with pytest.raises(TypeError, match=r"SQLAlchemy ORM expects"): + require_sqlalchemy_select(object()) + + +def test_apply_relationship_joins_deduplicates_repeated_join_plan() -> None: + """Applies one relationship join once even when repeated.""" + statement = apply_relationship_joins( + select(User), + ( + JoinPlanFixtures.company_inner(), + JoinPlanFixtures.company_inner(), + ), + ) + + sql = str(statement) + assert sql.count("JOIN company") == 1 + + +def test_apply_relationship_joins_rejects_right_join_hint() -> None: + """Rejects unsupported RIGHT join hints explicitly.""" + with pytest.raises(SQLAlchemyORMError, match=r"RIGHT joins"): + apply_relationship_joins( + select(User), + (JoinPlanFixtures.company_inner(),), + join_hints={"User.company": JoinHint.RIGHT}, + ) + + +class JoinPlanFixtures: + """Factory helpers for small statement-helper tests.""" + + @staticmethod + def company_inner(): + return SQLAlchemyJoinPlan( + key="User.company", + attribute=User.company, + default_hint=JoinHint.INNER, + is_collection=False, + ) From 804d008b065b3d692941a984c60059e7f7a7e4f1 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Fri, 12 Jun 2026 20:22:24 -0300 Subject: [PATCH 09/13] refactor: upgrade performance --- src/pyrsql/adapters/fastapi/config.py | 66 +++- src/pyrsql/adapters/fastapi/criteria.py | 27 +- src/pyrsql/adapters/fastapi/dependency.py | 58 +-- src/pyrsql/adapters/fastapi/errors.py | 366 +++++++++++------ src/pyrsql/core/conversion.py | 60 ++- src/pyrsql/core/custom.py | 19 +- src/pyrsql/core/json/query.py | 6 +- src/pyrsql/core/options.py | 54 +++ src/pyrsql/core/query.py | 4 +- src/pyrsql/core/sort.py | 2 +- src/pyrsql/core/source_issues.py | 64 +++ .../fastapi/sqlalchemy/__init__.py | 12 +- .../fastapi/sqlalchemy/examples.py | 134 ++++++- .../fastapi/sqlalchemy/helpers.py | 78 +--- .../fastapi/sqlalchemy/integration.py | 167 +++----- .../fastapi/sqlalchemy/payloads.py | 7 - .../fastapi/sqlalchemy/resource.py | 169 ++++---- src/pyrsql/orms/__init__.py | 2 + src/pyrsql/orms/base.py | 8 +- src/pyrsql/orms/sqlalchemy/coercion.py | 2 +- src/pyrsql/orms/sqlalchemy/errors.py | 14 +- src/pyrsql/orms/sqlalchemy/json_path.py | 8 +- src/pyrsql/orms/sqlalchemy/orm.py | 6 +- src/pyrsql/orms/sqlalchemy/resolver.py | 4 +- src/pyrsql/orms/sqlalchemy/sorter.py | 10 +- src/pyrsql/orms/sqlalchemy/translator.py | 18 +- src/pyrsql/parsing/diagnostics.py | 24 +- src/pyrsql/parsing/errors.py | 37 +- src/pyrsql/parsing/lexer.py | 44 +-- src/pyrsql/parsing/normalization.py | 51 +++ src/pyrsql/parsing/parser.py | 45 +-- src/pyrsql/semantic/diagnostics.py | 24 +- src/pyrsql/semantic/errors.py | 41 +- .../fastapi_adapter/test_async_dependency.py | 2 +- .../fastapi_adapter/test_dependency.py | 11 +- ...t_fastapi_sqlalchemy_error_sanitization.py | 10 +- tests/security/test_fastapi_sqlalchemy_hpp.py | 7 +- .../test_fastapi_sqlalchemy_injection.py | 4 +- .../test_fastapi_sqlalchemy_limits.py | 39 +- .../test_fastapi_sqlalchemy_policies.py | 19 +- ...st_fastapi_sqlalchemy_resource_security.py | 7 +- .../adapters/fastapi_adapter/test_config.py | 14 +- .../adapters/fastapi_adapter/test_criteria.py | 23 +- .../adapters/fastapi_adapter/test_errors.py | 30 ++ tests/unit/core/test_custom.py | 34 -- tests/unit/core/test_options.py | 32 ++ .../fastapi_sqlalchemy/test_examples.py | 99 +++++ .../fastapi_sqlalchemy/test_integration.py | 67 +++- uv.lock | 371 +++++++++--------- 49 files changed, 1379 insertions(+), 1021 deletions(-) create mode 100644 src/pyrsql/core/source_issues.py create mode 100644 src/pyrsql/parsing/normalization.py create mode 100644 tests/unit/adapters/fastapi_adapter/test_errors.py create mode 100644 tests/unit/integrations/fastapi_sqlalchemy/test_examples.py diff --git a/src/pyrsql/adapters/fastapi/config.py b/src/pyrsql/adapters/fastapi/config.py index 4c1fd66..c41f995 100644 --- a/src/pyrsql/adapters/fastapi/config.py +++ b/src/pyrsql/adapters/fastapi/config.py @@ -7,6 +7,7 @@ import msgspec from pyrsql.core.options import QueryOptions, SortOptions +from pyrsql.core.validation import validate_positive_int _DEFAULT_FILTER_PARAMETER = "filter" _DEFAULT_SORT_PARAMETER = "sort" @@ -17,6 +18,28 @@ _EMPTY_OPENAPI_EXAMPLES: Mapping[str, Any] = MappingProxyType({}) +def _normalize_parameter_name(name: object, *, field_name: str) -> str: + """Validates and normalizes one public FastAPI parameter name. + + Returns: + The validated parameter name. + + Raises: + TypeError: If the parameter name is not a string. + ValueError: If the parameter name is empty or has outer whitespace. + """ + if not isinstance(name, str): + raise TypeError(f"{field_name} must be a string.") + normalized_name = name.strip() + if not normalized_name: + raise ValueError("FastAPI parameter names must not be empty.") + if normalized_name != name: + raise ValueError( + "FastAPI parameter names must not contain outer whitespace.", + ) + return normalized_name + + class FastAPICriteriaConfig( msgspec.Struct, frozen=True, @@ -61,24 +84,35 @@ def __post_init__(self) -> None: limits are inconsistent. """ parameter_names = ( - self.filter_parameter, - self.sort_parameter, - self.page_parameter, - self.size_parameter, + _normalize_parameter_name( + self.filter_parameter, + field_name="filter_parameter", + ), + _normalize_parameter_name( + self.sort_parameter, + field_name="sort_parameter", + ), + _normalize_parameter_name( + self.page_parameter, + field_name="page_parameter", + ), + _normalize_parameter_name( + self.size_parameter, + field_name="size_parameter", + ), ) - normalized_names = tuple(name.strip() for name in parameter_names) - if any(not name for name in normalized_names): - raise ValueError("FastAPI parameter names must not be empty.") - if normalized_names != parameter_names: - raise ValueError( - "FastAPI parameter names must not contain outer whitespace.", - ) - if len(set(normalized_names)) != len(normalized_names): + if len(set(parameter_names)) != len(parameter_names): raise ValueError("FastAPI parameter names must be unique.") - if self.default_page_size is not None and self.default_page_size <= 0: - raise ValueError("default_page_size must be greater than 0.") - if self.max_page_size is not None and self.max_page_size <= 0: - raise ValueError("max_page_size must be greater than 0.") + if self.default_page_size is not None: + validate_positive_int( + self.default_page_size, + field_name="default_page_size", + ) + if self.max_page_size is not None: + validate_positive_int( + self.max_page_size, + field_name="max_page_size", + ) if ( self.default_page_size is not None and self.max_page_size is not None diff --git a/src/pyrsql/adapters/fastapi/criteria.py b/src/pyrsql/adapters/fastapi/criteria.py index 1850bb3..28c42c4 100644 --- a/src/pyrsql/adapters/fastapi/criteria.py +++ b/src/pyrsql/adapters/fastapi/criteria.py @@ -6,11 +6,10 @@ import msgspec -from pyrsql.core.page import PageRequest -from pyrsql.core.query import Query -from pyrsql.core.sort import Sort - if TYPE_CHECKING: + from pyrsql.core.page import PageRequest + from pyrsql.core.query import Query + from pyrsql.core.sort import Sort from pyrsql.orms.base import ORM _TargetT = TypeVar("_TargetT") @@ -29,24 +28,6 @@ class RequestCriteria( sort: Sort | None = None page_request: PageRequest | None = None - def __post_init__(self) -> None: - """Validates criteria payload types. - - Raises: - TypeError: If any provided criterion has the wrong runtime type. - """ - if self.query is not None and not isinstance(self.query, Query): - raise TypeError("query must be a Query instance or None.") - if self.sort is not None and not isinstance(self.sort, Sort): - raise TypeError("sort must be a Sort instance or None.") - if self.page_request is not None and not isinstance( - self.page_request, - PageRequest, - ): - raise TypeError( - "page_request must be a PageRequest instance or None.", - ) - @property def is_empty(self) -> bool: """Indicates whether no query components are present. @@ -72,6 +53,8 @@ def apply( Returns: The transformed ORM-specific target after applying all criteria. """ + if self.is_empty: + return target current_target = target if self.query is not None: current_target = self.query.apply( diff --git a/src/pyrsql/adapters/fastapi/dependency.py b/src/pyrsql/adapters/fastapi/dependency.py index f18e508..aff02fa 100644 --- a/src/pyrsql/adapters/fastapi/dependency.py +++ b/src/pyrsql/adapters/fastapi/dependency.py @@ -1,9 +1,7 @@ """Dependency factories for FastAPI request integration.""" from inspect import signature -from typing import TYPE_CHECKING, Annotated - -import msgspec +from typing import TYPE_CHECKING, Annotated, cast try: from fastapi import HTTPException, Query @@ -16,9 +14,7 @@ from pyrsql.adapters.fastapi.config import FastAPICriteriaConfig from pyrsql.adapters.fastapi.criteria import RequestCriteria from pyrsql.adapters.fastapi.errors import ( - build_page_error_payload, - build_query_error_payload, - build_sort_error_payload, + FastAPIAdapterErrorPayload, ) from pyrsql.core.page import PageRequest from pyrsql.core.query import Query as PyrsqlQuery @@ -37,10 +33,6 @@ from collections.abc import Callable from inspect import Signature - from pyrsql.adapters.fastapi.errors import ( - FastAPIAdapterErrorPayload, - ) - _QUERY_ERROR_TYPES = (ParseError, SemanticError) _SORT_ERROR_TYPES = ( SortParseError, @@ -61,12 +53,8 @@ def _raise_http_error( HTTPException: Always raised with a normalized adapter payload. """ raise HTTPException( - status_code=422, - detail={ - "parameter": payload.parameter, - "type": payload.error_type, - "errors": msgspec.to_builtins(payload.details), - }, + status_code=payload.status_code, + detail=payload.to_http_detail(), ) @@ -81,10 +69,12 @@ def _build_page_request( Returns: A normalized page request, or ``None`` when pagination is absent. - Raises: - ValueError: If internal validation leaves the resolved page size unset. """ - resolved_page_size = size_value or config.default_page_size + resolved_page_size = ( + config.default_page_size + if size_value is None + else size_value + ) if page_value is None: if resolved_page_size is None: @@ -95,9 +85,10 @@ def _build_page_request( if resolved_page_size is None: _raise_http_error( - build_page_error_payload( + FastAPIAdapterErrorPayload.from_page_error( config.size_parameter, error_type="page_configuration_error", + detail_code="missing_page_size", message=( f"'{config.size_parameter}' is required when " f"'{config.page_parameter}' is provided." @@ -108,9 +99,10 @@ def _build_page_request( if config.one_based_paging: if resolved_page_number <= 0: _raise_http_error( - build_page_error_payload( + FastAPIAdapterErrorPayload.from_page_error( config.page_parameter, error_type="page_validation_error", + detail_code="invalid_page_number", message=( f"'{config.page_parameter}' must be greater than 0 " "when one_based_paging is enabled." @@ -119,10 +111,10 @@ def _build_page_request( ) resolved_page_number -= 1 - page_size = resolved_page_size - if page_size is None: - raise ValueError("page_size cannot be None after page validation.") - return PageRequest.of(resolved_page_number, page_size) + return PageRequest.of( + resolved_page_number, + cast("int", resolved_page_size), + ) def _build_criteria_callable( @@ -178,7 +170,10 @@ def dependency( ) except _QUERY_ERROR_TYPES as error: _raise_http_error( - build_query_error_payload(config.filter_parameter, error), + FastAPIAdapterErrorPayload.from_query_error( + config.filter_parameter, + error, + ), ) if sort_value: @@ -189,7 +184,10 @@ def dependency( ) except _SORT_ERROR_TYPES as error: _raise_http_error( - build_sort_error_payload(config.sort_parameter, error), + FastAPIAdapterErrorPayload.from_sort_error( + config.sort_parameter, + error, + ), ) page_request = _build_page_request( @@ -225,7 +223,11 @@ def __init__( Args: config: Optional FastAPI criteria configuration. """ - self.config = config or _DEFAULT_FASTAPI_CRITERIA_CONFIG + self.config = ( + _DEFAULT_FASTAPI_CRITERIA_CONFIG + if config is None + else config + ) self._dependency = _build_criteria_callable(self.config) self.__signature__: Signature = signature(self._dependency) diff --git a/src/pyrsql/adapters/fastapi/errors.py b/src/pyrsql/adapters/fastapi/errors.py index 012e73e..a23d328 100644 --- a/src/pyrsql/adapters/fastapi/errors.py +++ b/src/pyrsql/adapters/fastapi/errors.py @@ -23,8 +23,18 @@ ) if TYPE_CHECKING: + + from pyrsql.orms import ORMError from pyrsql.parsing.source import SourceSpan + SortAdapterError = ( + SortParseError + | SortFieldNotWhitelistedError + | SortFieldBlacklistedError + | SortFunctionNotWhitelistedError + | SortFunctionBlacklistedError + ) + class FastAPIAdapterErrorPayload( msgspec.Struct, @@ -36,13 +46,186 @@ class FastAPIAdapterErrorPayload( Attributes: parameter: Name of the query parameter associated with the failure. - error_type: Stable machine-readable error category. + error_type: Stable machine-readable problem category. """ parameter: str error_type: str details: tuple[FastAPIAdapterErrorDetail, ...] + @classmethod + def _single_detail_payload( + cls, + *, + parameter_name: str, + error_type: str, + code: str, + detail: str, + field: str | None = None, + location: FastAPIAdapterErrorLocation | None = None, + ) -> FastAPIAdapterErrorPayload: + """Builds one payload with a single normalized detail item. + + Returns: + A normalized FastAPI adapter payload. + """ + return cls( + parameter=parameter_name, + error_type=error_type, + details=( + FastAPIAdapterErrorDetail( + code=code, + detail=detail, + field=field, + location=location, + ), + ), + ) + + @classmethod + def from_query_error( + cls, + parameter_name: str, + error: ParseError | SemanticError, + ) -> FastAPIAdapterErrorPayload: + """Builds a payload for one filter/query parsing or semantic error. + + Returns: + A normalized FastAPI adapter payload. + """ + error_type = "query_parse_error" + detail_code = getattr(error, "code", "query_parse_error") + detail_field = None + detail_location = None + if isinstance(error, SemanticError): + error_type = "query_semantic_error" + if isinstance( + error, + ( + FieldNotWhitelistedError, + FieldBlacklistedError, + FunctionNotWhitelistedError, + FunctionBlacklistedError, + ), + ): + detail_field = _extract_field_from_message(error.message) + detail_location = _build_error_location(error.span) + elif isinstance(error, ParseError): + detail_location = _build_error_location(error.span) + return cls._single_detail_payload( + parameter_name=parameter_name, + error_type=error_type, + code=detail_code, + detail=error.message, + field=detail_field, + location=detail_location, + ) + + @classmethod + def from_sort_error( + cls, + parameter_name: str, + error: SortAdapterError, + ) -> FastAPIAdapterErrorPayload: + """Builds a payload for one sort parsing or semantic error. + + Returns: + A normalized FastAPI adapter payload. + """ + error_type = "sort_parse_error" + detail_code = getattr(error, "code", "sort_parse_error") + detail_message = getattr(error, "message", str(error)) + detail_field = None + if isinstance( + error, + ( + SortFieldNotWhitelistedError, + SortFieldBlacklistedError, + SortFunctionNotWhitelistedError, + SortFunctionBlacklistedError, + ), + ): + error_type = "sort_semantic_error" + detail_field = _extract_field_from_message(detail_message) + return cls._single_detail_payload( + parameter_name=parameter_name, + error_type=error_type, + code=detail_code, + detail=detail_message, + field=detail_field, + ) + + @classmethod + def from_page_error( + cls, + parameter_name: str, + *, + error_type: str, + detail_code: str, + message: str, + ) -> FastAPIAdapterErrorPayload: + """Builds a payload for one page-related adapter validation error. + + Returns: + A normalized FastAPI adapter payload. + """ + return cls._single_detail_payload( + parameter_name=parameter_name, + error_type=error_type, + code=detail_code, + detail=message, + ) + + @classmethod + def from_backend_error( + cls, + parameter_name: str, + *, + error_type: str, + error: ORMError, + ) -> FastAPIAdapterErrorPayload: + """Builds a payload for one ORM backend integration failure. + + Returns: + A normalized FastAPI adapter payload. + """ + error_message = str(error) + return cls._single_detail_payload( + parameter_name=parameter_name, + error_type=error_type, + code=getattr(error, "code", "orm_error"), + detail=error_message, + field=_extract_field_from_message(error_message), + ) + + def to_http_detail(self) -> dict[str, object]: + """Builds the ``HTTPException.detail`` payload for this adapter error. + + Returns: + A JSON-compatible FastAPI error detail mapping. + """ + problem_definition = _problem_definition(self.error_type) + return { + "type": problem_definition.type_uri, + "title": problem_definition.title, + "parameter": self.parameter, + "detail": _top_level_detail(problem_definition.title, self.details), + "errors": list(msgspec.to_builtins(self.details)), + } + + @property + def status_code(self) -> int: + """Returns the HTTP status code associated with this problem.""" + return _problem_definition(self.error_type).status + + +class _ProblemDefinition(msgspec.Struct, frozen=True, gc=False): + """Stable definition for one top-level adapter problem category.""" + + type_uri: str + title: str + status: int + class FastAPIAdapterErrorLocation( msgspec.Struct, @@ -66,11 +249,74 @@ class FastAPIAdapterErrorDetail( """Structured detail item exposed in FastAPI adapter responses.""" code: str - message: str + detail: str field: str | None = None location: FastAPIAdapterErrorLocation | None = None +_PROBLEM_DEFINITIONS = { + "page_configuration_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:page-configuration-error", + title="Page configuration error", + status=400, + ), + "page_validation_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:page-validation-error", + title="Page validation error", + status=400, + ), + "query_backend_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:query-backend-error", + title="Query backend error", + status=422, + ), + "query_parse_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:query-parse-error", + title="Query parse error", + status=400, + ), + "query_semantic_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:query-semantic-error", + title="Query semantic error", + status=422, + ), + "sort_backend_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:sort-backend-error", + title="Sort backend error", + status=422, + ), + "sort_parse_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:sort-parse-error", + title="Sort parse error", + status=400, + ), + "sort_semantic_error": _ProblemDefinition( + type_uri="urn:pyrsql:problem:sort-semantic-error", + title="Sort semantic error", + status=422, + ), +} + + +def _problem_definition(error_type: str) -> _ProblemDefinition: + """Returns the top-level problem definition for one category.""" + return _PROBLEM_DEFINITIONS[error_type] + + +def _top_level_detail( + title: str, + details: tuple[FastAPIAdapterErrorDetail, ...], +) -> str: + """Builds the top-level problem detail summary. + + Returns: + One summary detail for the problem payload. + """ + if len(details) == 1: + return details[0].detail + return title + + def _extract_field_from_message(message: str) -> str | None: """Extracts a quoted field path from a normalized pyrsql message. @@ -100,119 +346,3 @@ def _build_error_location( line=span.start.line, column=span.start.column, ) - - -def build_query_error_payload( - parameter_name: str, - error: ParseError | SemanticError, -) -> FastAPIAdapterErrorPayload: - """Builds a payload for a filter/query parsing or semantic failure. - - Args: - parameter_name: Query parameter name associated with the failure. - error: Parsing or semantic error raised by pyrsql. - - Returns: - A normalized FastAPI error payload. - """ - error_type = "query_parse_error" - detail_code = getattr(error, "code", "query_parse_error") - detail_field = None - detail_location = None - if isinstance(error, SemanticError): - error_type = "query_semantic_error" - if isinstance( - error, - ( - FieldNotWhitelistedError, - FieldBlacklistedError, - FunctionNotWhitelistedError, - FunctionBlacklistedError, - ), - ): - detail_field = _extract_field_from_message(error.message) - detail_location = _build_error_location(error.span) - elif isinstance(error, ParseError): - detail_location = _build_error_location(error.span) - return FastAPIAdapterErrorPayload( - parameter=parameter_name, - error_type=error_type, - details=( - FastAPIAdapterErrorDetail( - code=detail_code, - message=error.message, - field=detail_field, - location=detail_location, - ), - ), - ) - - -def build_sort_error_payload( - parameter_name: str, - error: Exception, -) -> FastAPIAdapterErrorPayload: - """Builds a payload for a sort parsing or semantic failure. - - Args: - parameter_name: Query parameter name associated with the failure. - error: Sorting error raised by pyrsql. - - Returns: - A normalized FastAPI error payload. - """ - error_type = "sort_error" - detail_code = getattr(error, "code", "sort_error") - detail_field = None - if isinstance(error, SortParseError): - error_type = "sort_parse_error" - elif isinstance( - error, - ( - SortFieldNotWhitelistedError, - SortFieldBlacklistedError, - SortFunctionNotWhitelistedError, - SortFunctionBlacklistedError, - ), - ): - error_type = "sort_semantic_error" - detail_field = _extract_field_from_message(str(error)) - return FastAPIAdapterErrorPayload( - parameter=parameter_name, - error_type=error_type, - details=( - FastAPIAdapterErrorDetail( - code=detail_code, - message=getattr(error, "message", str(error)), - field=detail_field, - ), - ), - ) - - -def build_page_error_payload( - parameter_name: str, - *, - error_type: str, - message: str, -) -> FastAPIAdapterErrorPayload: - """Builds a payload for page-related adapter validation failures. - - Args: - parameter_name: Query parameter name associated with the failure. - error_type: Machine-readable error category. - message: Human-readable error message. - - Returns: - A normalized FastAPI error payload. - """ - return FastAPIAdapterErrorPayload( - parameter=parameter_name, - error_type=error_type, - details=( - FastAPIAdapterErrorDetail( - code=error_type, - message=message, - ), - ), - ) diff --git a/src/pyrsql/core/conversion.py b/src/pyrsql/core/conversion.py index e81effe..473e47f 100644 --- a/src/pyrsql/core/conversion.py +++ b/src/pyrsql/core/conversion.py @@ -27,24 +27,19 @@ class ValueConversionError(ValueError): """Raised when a raw RSQL value cannot be converted.""" - -def _build_conversion_error( - raw_value: RawValue, - target_type: TargetType, - *, - cause: Exception | None = None, -) -> ValueConversionError: - """Builds one normalized conversion error instance. - - Returns: - A normalized value conversion error. - """ - error = ValueConversionError( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", - ) - if cause is not None: - error.__cause__ = cause - return error + def __init__( + self, + raw_value: RawValue, + target_type: TargetType, + *, + cause: Exception | None = None, + ) -> None: + """Builds one normalized conversion error instance.""" + super().__init__( + f"Failed to convert {raw_value!r} to {target_type.__name__}.", + ) + if cause is not None: + self.__cause__ = cause def _convert_bool(raw_value: RawValue) -> bool: @@ -74,8 +69,8 @@ def _convert_datetime(raw_value: RawValue) -> dt.datetime: The converted datetime value. Raises: - _build_conversion_error: If the input cannot be parsed as datetime or - ISO date. + ValueConversionError: If the input cannot be parsed as datetime or ISO + date. """ try: return msgspec.convert( @@ -88,7 +83,7 @@ def _convert_datetime(raw_value: RawValue) -> dt.datetime: try: parsed_date = dt.date.fromisoformat(raw_value) except ValueError as date_error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, dt.datetime, cause=date_error, @@ -113,7 +108,7 @@ def converter(raw_value: RawValue) -> ConvertedValue: strict=False, ) except msgspec.ValidationError as error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, @@ -208,15 +203,13 @@ def _convert_with_registered_converter( Raises: ValueConversionError: If the converter fails. - _build_conversion_error: If the converter raises an unexpected - exception. """ try: return converter(raw_value) except ValueConversionError: raise except Exception as error: # pragma: no cover - defensive wrap - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, @@ -259,7 +252,7 @@ def _convert_enum( The resolved enum member. Raises: - _build_conversion_error: If no enum member matches the raw value. + ValueConversionError: If no enum member matches the raw value. """ try: return target_type[raw_value] @@ -267,7 +260,7 @@ def _convert_enum( try: return target_type(raw_value) except ValueError as error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, @@ -284,12 +277,12 @@ def _construct_from_string( The converted value produced by the target type constructor. Raises: - _build_conversion_error: If the target type constructor fails. + ValueConversionError: If the target type constructor fails. """ try: return target_type(raw_value) except Exception as error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, @@ -306,9 +299,8 @@ def _convert_json_container( The converted mapping or sequence value. Raises: - ValueConversionError: If the target type is not a supported JSON - container type. - _build_conversion_error: If decoding or rewrapping fails. + ValueConversionError: If conversion to the requested JSON + container type fails. """ expected_type: TargetType if issubclass(target_type, dict): @@ -325,7 +317,7 @@ def _convert_json_container( try: decoded = decoder.decode(raw_value) except (msgspec.DecodeError, msgspec.ValidationError) as error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, @@ -336,7 +328,7 @@ def _convert_json_container( try: return target_type(decoded) except Exception as error: - raise _build_conversion_error( + raise ValueConversionError( raw_value, target_type, cause=error, diff --git a/src/pyrsql/core/custom.py b/src/pyrsql/core/custom.py index 49a1bbe..f5f5b50 100644 --- a/src/pyrsql/core/custom.py +++ b/src/pyrsql/core/custom.py @@ -2,11 +2,12 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import msgspec -from pyrsql.parsing.operators import ComparisonOperator +if TYPE_CHECKING: + from pyrsql.parsing.operators import ComparisonOperator ArgumentType = type[Any] @@ -27,17 +28,3 @@ class CustomPredicateDefinition( operator: ComparisonOperator argument_type: ArgumentType - - def __post_init__(self) -> None: - """Validates custom predicate invariants. - - Raises: - TypeError: If the operator or argument type has the wrong runtime - type. - """ - if not isinstance(self.operator, ComparisonOperator): - raise TypeError( - "operator must be a ComparisonOperator instance.", - ) - if not isinstance(self.argument_type, type): - raise TypeError("argument_type must be a runtime type.") diff --git a/src/pyrsql/core/json/query.py b/src/pyrsql/core/json/query.py index a38ded6..40f6721 100644 --- a/src/pyrsql/core/json/query.py +++ b/src/pyrsql/core/json/query.py @@ -46,7 +46,11 @@ def from_raw_arguments( Returns: One normalized JSON path comparison. """ - value_normalizer = normalizer or DEFAULT_JSON_SCALAR_NORMALIZER + value_normalizer = ( + DEFAULT_JSON_SCALAR_NORMALIZER + if normalizer is None + else normalizer + ) return cls( path=path, operator_name=operator_name, diff --git a/src/pyrsql/core/options.py b/src/pyrsql/core/options.py index 93ffb10..ac4e130 100644 --- a/src/pyrsql/core/options.py +++ b/src/pyrsql/core/options.py @@ -440,6 +440,37 @@ def _build_field_converter_set(self) -> FieldValueConverterSet: model_field_converters=self.model_field_value_converters, ) + def with_field_whitelist( + self, + field_whitelist: frozenset[str], + ) -> QueryOptions: + """Returns one copy with only the global field whitelist replaced. + + Returns: + A query options copy with the provided field whitelist. + """ + return QueryOptions( + strict_equality=self.strict_equality, + distinct=self.distinct, + like_escape_character=self.like_escape_character, + field_mapping=self.field_mapping, + model_field_mapping=self.model_field_mapping, + join_hints=self.join_hints, + field_whitelist=field_whitelist, + field_blacklist=self.field_blacklist, + model_field_whitelist=self.model_field_whitelist, + model_field_blacklist=self.model_field_blacklist, + procedure_whitelist=self.procedure_whitelist, + procedure_blacklist=self.procedure_blacklist, + parse_limits=self.parse_limits, + operator_registry=self.operator_registry, + custom_predicates=self.custom_predicates, + value_converter_registry=self.value_converter_registry, + field_value_converters=self.field_value_converters, + model_field_value_converters=self.model_field_value_converters, + json_options=self.json_options, + ) + class SortOptions(msgspec.Struct, frozen=True, gc=False, kw_only=True): """ORM-neutral sort options.""" @@ -501,6 +532,29 @@ def procedure_policy(self) -> ProcedureAccessPolicy: """ return self._procedure_policy + def with_field_whitelist( + self, + field_whitelist: frozenset[str], + ) -> SortOptions: + """Returns one copy with only the global field whitelist replaced. + + Returns: + A sort options copy with the provided field whitelist. + """ + return SortOptions( + field_mapping=self.field_mapping, + model_field_mapping=self.model_field_mapping, + join_hints=self.join_hints, + field_whitelist=field_whitelist, + field_blacklist=self.field_blacklist, + model_field_whitelist=self.model_field_whitelist, + model_field_blacklist=self.model_field_blacklist, + procedure_whitelist=self.procedure_whitelist, + procedure_blacklist=self.procedure_blacklist, + sort_limits=self.sort_limits, + json_options=self.json_options, + ) + def _normalize_nested_mapping( mapping: Mapping[type[Any], Mapping[str, _NestedValueT]], diff --git a/src/pyrsql/core/query.py b/src/pyrsql/core/query.py index 0f126fc..4e0ddfd 100644 --- a/src/pyrsql/core/query.py +++ b/src/pyrsql/core/query.py @@ -56,7 +56,9 @@ def parse( Returns: A parsed query object. """ - resolved_options = options or _DEFAULT_QUERY_OPTIONS + resolved_options = ( + _DEFAULT_QUERY_OPTIONS if options is None else options + ) expression = cls.parse_expression(query_text, options=resolved_options) bound_expression = cls.bind_expression( expression, diff --git a/src/pyrsql/core/sort.py b/src/pyrsql/core/sort.py index 551ffbf..4a1cebe 100644 --- a/src/pyrsql/core/sort.py +++ b/src/pyrsql/core/sort.py @@ -53,7 +53,7 @@ def parse( Returns: A parsed sort object. """ - resolved_options = options or _DEFAULT_SORT_OPTIONS + resolved_options = _DEFAULT_SORT_OPTIONS if options is None else options fields = cls.parse_fields(sort_text, options=resolved_options) bound_sort = cls.bind_fields(fields, options=resolved_options) return cls( diff --git a/src/pyrsql/core/source_issues.py b/src/pyrsql/core/source_issues.py new file mode 100644 index 0000000..03bfec2 --- /dev/null +++ b/src/pyrsql/core/source_issues.py @@ -0,0 +1,64 @@ +"""Shared source-location-aware diagnostics and errors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +import msgspec + +if TYPE_CHECKING: + from pyrsql.parsing.source import SourceSpan + + +class SourceDiagnostic(msgspec.Struct, frozen=True, gc=False, kw_only=True): + """Structured diagnostic tied to one source span.""" + + code: str + message: str + span: SourceSpan + + def __str__(self) -> str: + """Formats the diagnostic with source position data. + + Returns: + The formatted diagnostic string. + """ + return ( + f"[{self.code}] {self.message} at index {self.span.start.index} " + f"(line {self.span.start.line}, column {self.span.start.column})" + ) + + +@dataclass(frozen=True, slots=True) +class SourceError(ValueError): + """Base exception for failures tied to one source span.""" + + message: str + span: SourceSpan + code: ClassVar[str] = "source_error" + diagnostic_type: ClassVar[type[SourceDiagnostic]] = SourceDiagnostic + + @property + def diagnostic(self) -> SourceDiagnostic: + """Returns the structured diagnostic for this error. + + Returns: + The structured source diagnostic. + """ + return self.diagnostic_type( + code=self.code, + message=self.message, + span=self.span, + ) + + def __str__(self) -> str: + """Formats the error with source location data. + + Returns: + The formatted error string. + """ + return ( + f"[{self.code}] {self.message} at index {self.span.start.index} " + f"(line {self.span.start.line}, column {self.span.start.column})" + ) diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/__init__.py b/src/pyrsql/integrations/fastapi/sqlalchemy/__init__.py index 32606f2..f8f72b8 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/__init__.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/__init__.py @@ -1,8 +1,14 @@ """FastAPI + SQLAlchemy integration helpers.""" -from .integration import FastAPISQLAlchemyIntegration -from .payloads import SQLAlchemyPaginatedSelect -from .resource import FastAPISQLAlchemyResource +from pyrsql.integrations.fastapi.sqlalchemy.integration import ( + FastAPISQLAlchemyIntegration, +) +from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, +) +from pyrsql.integrations.fastapi.sqlalchemy.resource import ( + FastAPISQLAlchemyResource, +) __all__ = ( "FastAPISQLAlchemyIntegration", diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py b/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py index af7abd2..2c71cfe 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py @@ -1,8 +1,29 @@ """OpenAPI example generation helpers for FastAPI SQLAlchemy resources.""" +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from enum import Enum +from typing import TYPE_CHECKING, Any, Final +from uuid import UUID + +from pyrsql.core.field_policy import FieldPolicySet +from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver + +if TYPE_CHECKING: + from collections.abc import Mapping + + from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel + +_DEFAULT_PATH_RESOLVER: Final = SQLAlchemyPathResolver() _DEFAULT_STRING_EXAMPLE = "demo" -_DEFAULT_NUMERIC_EXAMPLE = 1 +_DEFAULT_INTEGER_EXAMPLE = "1" +_DEFAULT_FLOAT_EXAMPLE = "1.5" +_DEFAULT_BOOLEAN_EXAMPLE = "true" _DEFAULT_DATE_EXAMPLE = "2026-01-01" +_DEFAULT_DATETIME_EXAMPLE = "2026-01-01T10:30:00" +_DEFAULT_UUID_EXAMPLE = "12345678-1234-5678-1234-567812345678" def normalize_default_sort(default_sort: str) -> str: @@ -26,35 +47,102 @@ def normalize_default_sort(default_sort: str) -> str: return stripped -def field_example_value(field_path: str) -> str | int: - """Builds a simple example value from a field path. +def _build_example_resolution_policy( + field_policy: FieldPolicySet | None, +) -> FieldPolicySet | None: + """Builds the minimal policy needed for example type resolution. + + Returns: + A reduced field policy carrying only model-specific field mappings. + """ + if field_policy is None or not field_policy.model_field_mapping: + return None + return FieldPolicySet( + field_whitelist=frozenset(), + field_blacklist=frozenset(), + model_field_mapping=field_policy.model_field_mapping, + model_field_whitelist={}, + model_field_blacklist={}, + ) + + +def filter_example_value( + python_type: type[Any] | None, + *, + is_json: bool = False, +) -> str: + """Builds one scalar filter example literal from a resolved Python type. Returns: - A simple scalar example value inferred from the field path. + A filter-compatible scalar literal. """ - normalized = field_path.lower() - if normalized == "id" or normalized.endswith((".id", "_id")): - return _DEFAULT_NUMERIC_EXAMPLE - if any( - token in normalized - for token in ("date", "time", "created_at", "updated_at") - ): + if is_json or python_type is None: + return _DEFAULT_STRING_EXAMPLE + if issubclass(python_type, Enum): + return _enum_filter_example_value(python_type) + if issubclass(python_type, bool): + return _DEFAULT_BOOLEAN_EXAMPLE + if issubclass(python_type, int): + return _DEFAULT_INTEGER_EXAMPLE + if issubclass(python_type, (float, Decimal)): + return _DEFAULT_FLOAT_EXAMPLE + if issubclass(python_type, dt.datetime): + return _DEFAULT_DATETIME_EXAMPLE + if issubclass(python_type, dt.date): return _DEFAULT_DATE_EXAMPLE + if issubclass(python_type, UUID): + return _DEFAULT_UUID_EXAMPLE return _DEFAULT_STRING_EXAMPLE -def format_filter_example(field_path: str) -> str: - """Formats one automatic filter example. +def _enum_filter_example_value(enum_type: type[Any]) -> str: + """Builds one filter example literal for an enum type. + + Returns: + A filter-compatible enum literal. + """ + try: + first_member = next(iter(enum_type)) + except StopIteration: + return _DEFAULT_STRING_EXAMPLE + enum_value = first_member.value + if isinstance(enum_value, bool): + return "true" if enum_value else "false" + return str(enum_value) + + +def format_filter_example( + model: SQLAlchemyModel, + field_path: str, + *, + field_mapping: Mapping[str, str], + field_policy: FieldPolicySet | None, + path_resolver: SQLAlchemyPathResolver, +) -> str: + """Formats one automatic filter example from resolved ORM metadata. Returns: One automatic filter expression example. """ - value = field_example_value(field_path) - return f"{field_path}=={value}" + resolved_path = path_resolver.resolve( + model, + field_mapping.get(field_path, field_path), + field_policy=field_policy, + ) + example_value = filter_example_value( + resolved_path.python_type, + is_json=resolved_path.is_json, + ) + return f"{field_path}=={example_value}" def build_filter_examples( + model: SQLAlchemyModel, filterable_fields: set[str] | frozenset[str] | None, + *, + field_mapping: Mapping[str, str] | None = None, + field_policy: FieldPolicySet | None = None, + path_resolver: SQLAlchemyPathResolver | None = None, ) -> dict[str, dict[str, object]]: """Builds automatic filter examples from declarative field config. @@ -63,12 +151,24 @@ def build_filter_examples( """ if not filterable_fields: return {} + + active_field_mapping = {} if field_mapping is None else field_mapping + active_path_resolver = ( + _DEFAULT_PATH_RESOLVER if path_resolver is None else path_resolver + ) + resolution_policy = _build_example_resolution_policy(field_policy) examples: dict[str, dict[str, object]] = {} for field_path in sorted(filterable_fields)[:3]: key = field_path.replace(".", "_") examples[f"filter_by_{key}"] = { "summary": f"Filter by {field_path}", - "value": format_filter_example(field_path), + "value": format_filter_example( + model, + field_path, + field_mapping=active_field_mapping, + field_policy=resolution_policy, + path_resolver=active_path_resolver, + ), } return examples @@ -107,7 +207,7 @@ def merge_openapi_examples( Returns: The merged OpenAPI example mapping. """ - if not explicit: + if explicit is None: return generated merged = dict(generated) merged.update(explicit) diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py index 60a185e..1054582 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py @@ -6,14 +6,10 @@ from typing import TYPE_CHECKING from fastapi import HTTPException -import msgspec from sqlalchemy import func, select from pyrsql.adapters.fastapi import RequestCriteria -from pyrsql.adapters.fastapi.errors import ( - FastAPIAdapterErrorDetail, - FastAPIAdapterErrorPayload, -) +from pyrsql.adapters.fastapi.errors import FastAPIAdapterErrorPayload from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError if TYPE_CHECKING: @@ -25,22 +21,6 @@ from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect -def _extract_field_from_message(message: str) -> str | None: - """Extracts a quoted field path from backend error messages. - - Returns: - The extracted field path, or ``None`` when no quoted field exists. - """ - quote_start = message.find("'") - if quote_start < 0: - return None - quote_end = message.find("'", quote_start + 1) - if quote_end < 0: - return None - field = message[quote_start + 1 : quote_end] - return field or None - - def _raise_backend_http_error( *, parameter_name: str, @@ -52,48 +32,14 @@ def _raise_backend_http_error( Raises: HTTPException: Always raised with a normalized error payload. """ - payload = FastAPIAdapterErrorPayload( - parameter=parameter_name, + payload = FastAPIAdapterErrorPayload.from_backend_error( + parameter_name, error_type=error_type, - details=( - FastAPIAdapterErrorDetail( - code=error_type, - message=str(error), - field=_extract_field_from_message(str(error)), - ), - ), - ) - raise HTTPException( - status_code=422, - detail={ - "parameter": payload.parameter, - "type": payload.error_type, - "errors": msgspec.to_builtins(payload.details), - }, - ) - - -def raise_query_backend_http_error( - config: "FastAPICriteriaConfig", # noqa: UP037 - error: SQLAlchemyORMError, -) -> NoReturn: - """Raises one normalized HTTP 422 query error for backend failures.""" - _raise_backend_http_error( - parameter_name=config.filter_parameter, - error_type="query_backend_error", error=error, ) - - -def raise_sort_backend_http_error( - config: "FastAPICriteriaConfig", # noqa: UP037 - error: SQLAlchemyORMError, -) -> NoReturn: - """Raises one normalized HTTP 422 sort error for backend failures.""" - _raise_backend_http_error( - parameter_name=config.sort_parameter, - error_type="sort_backend_error", - error=error, + raise HTTPException( + status_code=payload.status_code, + detail=payload.to_http_detail(), ) @@ -105,7 +51,11 @@ def query_backend_http_errors( try: yield except SQLAlchemyORMError as error: - raise_query_backend_http_error(config, error) + _raise_backend_http_error( + parameter_name=config.filter_parameter, + error_type="query_backend_error", + error=error, + ) @contextmanager @@ -116,7 +66,11 @@ def sort_backend_http_errors( try: yield except SQLAlchemyORMError as error: - raise_sort_backend_http_error(config, error) + _raise_backend_http_error( + parameter_name=config.sort_parameter, + error_type="sort_backend_error", + error=error, + ) def require_request_criteria(criteria: RequestCriteria) -> RequestCriteria: diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index 80266f4..f42fc4c 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -3,7 +3,6 @@ from __future__ import annotations from threading import Lock -from types import MappingProxyType from typing import TYPE_CHECKING from fastapi import Depends @@ -13,18 +12,14 @@ CriteriaDependency, FastAPICriteriaConfig, ) -from pyrsql.core.options import QueryOptions, SortOptions from pyrsql.core.sort import Sort as PyrsqlSort -from pyrsql.orms.sqlalchemy import SQLAlchemyORM -from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select - -from .examples import ( +from pyrsql.integrations.fastapi.sqlalchemy.examples import ( build_filter_examples, build_sort_examples, merge_openapi_examples, normalize_default_sort, ) -from .helpers import ( +from pyrsql.integrations.fastapi.sqlalchemy.helpers import ( apply_query_with_orm, apply_sort_and_page_with_orm, count_from_filtered_select, @@ -32,8 +27,14 @@ require_request_criteria, sort_backend_http_errors, ) -from .payloads import SQLAlchemyPaginatedSelect -from .resource import FastAPISQLAlchemyResource +from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, +) +from pyrsql.integrations.fastapi.sqlalchemy.resource import ( + FastAPISQLAlchemyResource, +) +from pyrsql.orms.sqlalchemy import SQLAlchemyORM +from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select if TYPE_CHECKING: from collections.abc import Callable @@ -44,7 +45,6 @@ from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect _DEFAULT_FASTAPI_CRITERIA_CONFIG = FastAPICriteriaConfig() -_EMPTY_OPENAPI_EXAMPLES = MappingProxyType({}) class FastAPISQLAlchemyIntegration: @@ -82,9 +82,11 @@ def __init__( raise TypeError( "criteria_config must be a FastAPICriteriaConfig or None.", ) - self.orm = orm or SQLAlchemyORM() + self.orm = SQLAlchemyORM() if orm is None else orm self.criteria_config = ( - criteria_config or _DEFAULT_FASTAPI_CRITERIA_CONFIG + _DEFAULT_FASTAPI_CRITERIA_CONFIG + if criteria_config is None + else criteria_config ) self._criteria_dependency = CriteriaDependency(self.criteria_config) self._cache_lock = Lock() @@ -110,54 +112,6 @@ def criteria_dependency(self) -> CriteriaDependency: """ return self._criteria_dependency - @staticmethod - def _with_query_field_whitelist( - options: QueryOptions, - field_whitelist: frozenset[str], - ) -> QueryOptions: - """Returns query options overriding only the field whitelist.""" - return QueryOptions( - strict_equality=options.strict_equality, - distinct=options.distinct, - like_escape_character=options.like_escape_character, - field_mapping=options.field_mapping, - model_field_mapping=options.model_field_mapping, - join_hints=options.join_hints, - field_whitelist=field_whitelist, - field_blacklist=options.field_blacklist, - model_field_whitelist=options.model_field_whitelist, - model_field_blacklist=options.model_field_blacklist, - procedure_whitelist=options.procedure_whitelist, - procedure_blacklist=options.procedure_blacklist, - parse_limits=options.parse_limits, - operator_registry=options.operator_registry, - custom_predicates=options.custom_predicates, - value_converter_registry=options.value_converter_registry, - field_value_converters=options.field_value_converters, - model_field_value_converters=(options.model_field_value_converters), - json_options=options.json_options, - ) - - @staticmethod - def _with_sort_field_whitelist( - options: SortOptions, - field_whitelist: frozenset[str], - ) -> SortOptions: - """Returns sort options overriding only the field whitelist.""" - return SortOptions( - field_mapping=options.field_mapping, - model_field_mapping=options.model_field_mapping, - join_hints=options.join_hints, - field_whitelist=field_whitelist, - field_blacklist=options.field_blacklist, - model_field_whitelist=options.model_field_whitelist, - model_field_blacklist=options.model_field_blacklist, - procedure_whitelist=options.procedure_whitelist, - procedure_blacklist=options.procedure_blacklist, - sort_limits=options.sort_limits, - json_options=options.json_options, - ) - def _apply_query( self, statement: SQLAlchemySelect, @@ -204,13 +158,13 @@ def _filtered_select( Returns: The shared filtered select statement used by list and count flows. """ - return self._apply_query(self._base_select(model), model, criteria) + return self._apply_query(self.base_select(model), model, criteria) - def _base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: - """Returns a cached base select(model) statement. + def base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: + """Returns the cached base select for a model. Returns: - A cached base ``select(model)`` statement. + The cached base ``select(model)`` statement. """ statement = self._base_selects.get(model) if statement is not None: @@ -223,25 +177,6 @@ def _base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: self._base_selects[model] = statement return statement - def base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: - """Returns the cached base select for a model. - - Returns: - The cached base ``select(model)`` statement. - """ - return self._base_select(model) - - @staticmethod - def _count_from_filtered_select( - filtered_statement: SQLAlchemySelect, - ) -> SQLAlchemySelect: - """Builds a count statement from an already-filtered select. - - Returns: - A count statement derived from the filtered select. - """ - return count_from_filtered_select(filtered_statement) - def apply( self, statement: SQLAlchemySelect, @@ -286,7 +221,7 @@ def count_select( A count statement derived from the filtered query semantics. """ criteria = require_request_criteria(criteria) - return self._count_from_filtered_select( + return count_from_filtered_select( self._filtered_select(model, criteria), ) @@ -308,9 +243,7 @@ def paginated_select( model, criteria, ), - count_statement=self._count_from_filtered_select( - filtered_statement, - ), + count_statement=count_from_filtered_select(filtered_statement), ) def select_dependency( @@ -334,17 +267,16 @@ def select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): filtered_statement = self._filtered_select( model, - validated_criteria, + criteria, ) with sort_backend_http_errors(self.criteria_config): return self._apply_sort_and_page( filtered_statement, model, - validated_criteria, + criteria, ) self._select_dependencies[model] = dependency @@ -371,13 +303,12 @@ def count_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): filtered_statement = self._filtered_select( model, - validated_criteria, + criteria, ) - return self._count_from_filtered_select(filtered_statement) + return count_from_filtered_select(filtered_statement) self._count_select_dependencies[model] = dependency return dependency @@ -403,20 +334,19 @@ def paginated_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemyPaginatedSelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): filtered_statement = self._filtered_select( model, - validated_criteria, + criteria, ) with sort_backend_http_errors(self.criteria_config): return SQLAlchemyPaginatedSelect( statement=self._apply_sort_and_page( filtered_statement, model, - validated_criteria, + criteria, ), - count_statement=self._count_from_filtered_select( + count_statement=count_from_filtered_select( filtered_statement, ), ) @@ -448,18 +378,21 @@ def resource( query_options = self.criteria_config.query_options sort_options = self.criteria_config.sort_options if filterable_fields is not None: - query_options = self._with_query_field_whitelist( - query_options, + query_options = query_options.with_field_whitelist( frozenset(filterable_fields), ) if sortable_fields is not None: - sort_options = self._with_sort_field_whitelist( - sort_options, + sort_options = sort_options.with_field_whitelist( frozenset(sortable_fields), ) filter_openapi_examples = merge_openapi_examples( - build_filter_examples(filterable_fields), + build_filter_examples( + model, + filterable_fields, + field_mapping=query_options.field_mapping, + field_policy=query_options.field_policy, + ), filter_examples, ) sort_openapi_examples = merge_openapi_examples( @@ -469,27 +402,43 @@ def resource( criteria_config = FastAPICriteriaConfig( filter_parameter=( - query_parameter_name or self.criteria_config.filter_parameter + self.criteria_config.filter_parameter + if query_parameter_name is None + else query_parameter_name ), sort_parameter=( - sort_parameter_name or self.criteria_config.sort_parameter + self.criteria_config.sort_parameter + if sort_parameter_name is None + else sort_parameter_name ), page_parameter=( - page_parameter_name or self.criteria_config.page_parameter + self.criteria_config.page_parameter + if page_parameter_name is None + else page_parameter_name ), size_parameter=( - size_parameter_name or self.criteria_config.size_parameter + self.criteria_config.size_parameter + if size_parameter_name is None + else size_parameter_name ), default_page_size=self.criteria_config.default_page_size, - max_page_size=max_page_size or self.criteria_config.max_page_size, + max_page_size=( + self.criteria_config.max_page_size + if max_page_size is None + else max_page_size + ), one_based_paging=self.criteria_config.one_based_paging, query_options=query_options, sort_options=sort_options, filter_openapi_examples=( - filter_openapi_examples or _EMPTY_OPENAPI_EXAMPLES + {} + if filter_openapi_examples is None + else filter_openapi_examples ), sort_openapi_examples=( - sort_openapi_examples or _EMPTY_OPENAPI_EXAMPLES + {} + if sort_openapi_examples is None + else sort_openapi_examples ), page_openapi_examples=self.criteria_config.page_openapi_examples, size_openapi_examples=self.criteria_config.size_openapi_examples, diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/payloads.py b/src/pyrsql/integrations/fastapi/sqlalchemy/payloads.py index 5e1e7d4..07f4a3d 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/payloads.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/payloads.py @@ -6,8 +6,6 @@ import msgspec -from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select - if TYPE_CHECKING: from pyrsql.orms.sqlalchemy.types import SQLAlchemySelect @@ -22,8 +20,3 @@ class SQLAlchemyPaginatedSelect( statement: SQLAlchemySelect count_statement: SQLAlchemySelect - - def __post_init__(self) -> None: - """Validates the carried SQLAlchemy statements.""" - require_sqlalchemy_select(self.statement) - require_sqlalchemy_select(self.count_statement) diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py index dd359bc..38a1bf0 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py @@ -3,7 +3,7 @@ from __future__ import annotations from threading import Lock -from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable +from typing import TYPE_CHECKING from fastapi import Depends @@ -13,9 +13,7 @@ RequestCriteria, ) from pyrsql.core.sort import Sort as PyrsqlSort -from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select - -from .helpers import ( +from pyrsql.integrations.fastapi.sqlalchemy.helpers import ( apply_query_with_orm, apply_sort_and_page_with_orm, count_from_filtered_select, @@ -23,41 +21,28 @@ require_request_criteria, sort_backend_http_errors, ) -from .payloads import SQLAlchemyPaginatedSelect +import pyrsql.integrations.fastapi.sqlalchemy.integration +from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, +) +from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select if TYPE_CHECKING: from collections.abc import Callable - from pyrsql.orms.sqlalchemy import SQLAlchemyORM + from pyrsql.integrations.fastapi.sqlalchemy.integration import ( + FastAPISQLAlchemyIntegration, + ) from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect - from .integration import FastAPISQLAlchemyIntegration - - -@runtime_checkable -class _ResourceIntegrationProtocol(Protocol): - """Structural contract required by declarative resources.""" - - orm: SQLAlchemyORM - - def apply( - self, - statement: SQLAlchemySelect, - model: SQLAlchemyModel, - criteria: RequestCriteria, - ) -> SQLAlchemySelect: ... - - def base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: ... - class FastAPISQLAlchemyResource: """Declarative FastAPI + SQLAlchemy resource integration.""" - integration: _ResourceIntegrationProtocol + integration: FastAPISQLAlchemyIntegration model: SQLAlchemyModel criteria_config: FastAPICriteriaConfig _criteria_dependency: Callable[..., RequestCriteria] - _default_sort: PyrsqlSort | None _statement_factory: Callable[[], SQLAlchemySelect] | None _applier_dependency: ( Callable[..., Callable[[SQLAlchemySelect], SQLAlchemySelect]] | None @@ -73,7 +58,6 @@ class FastAPISQLAlchemyResource: "_cache_lock", "_count_select_dependency", "_criteria_dependency", - "_default_sort", "_paginated_select_dependency", "_select_dependency", "_statement_factory", @@ -97,14 +81,16 @@ def __init__( TypeError: If the integration, criteria config, default sort, or statement factory have the wrong runtime type. """ - if not isinstance(integration, _ResourceIntegrationProtocol): + integration_type = ( + pyrsql.integrations.fastapi.sqlalchemy.integration.FastAPISQLAlchemyIntegration + ) + if not isinstance( + integration, + integration_type, + ): raise TypeError( "integration must be a FastAPISQLAlchemyIntegration.", ) - validated_integration = cast( - "_ResourceIntegrationProtocol", - integration, - ) if not isinstance(criteria_config, FastAPICriteriaConfig): raise TypeError("criteria_config must be a FastAPICriteriaConfig.") if default_sort is not None and not isinstance( @@ -114,10 +100,9 @@ def __init__( raise TypeError("default_sort must be a Sort or None.") if statement_factory is not None and not callable(statement_factory): raise TypeError("statement_factory must be a callable or None.") - self.integration = validated_integration + self.integration = integration self.model = model self.criteria_config = criteria_config - self._default_sort = default_sort self._statement_factory = statement_factory self._cache_lock = Lock() self._applier_dependency: ( @@ -144,7 +129,7 @@ def dependency( return criteria return RequestCriteria( query=criteria.query, - sort=self._default_sort, + sort=default_sort, page_request=criteria.page_request, ) @@ -213,47 +198,80 @@ def apply_to(statement: SQLAlchemySelect) -> SQLAlchemySelect: return apply_to - def count_select(self, criteria: RequestCriteria) -> SQLAlchemySelect: - """Builds a count select for the configured resource model. + def _filtered_statement( + self, + criteria: RequestCriteria, + ) -> SQLAlchemySelect: + """Builds the filtered base statement for validated criteria. Returns: - A count SQLAlchemy select statement. + The filtered base statement. """ - validated_criteria = require_request_criteria(criteria) - filtered_statement = apply_query_with_orm( + return apply_query_with_orm( self._base_statement(), self.model, - validated_criteria, + criteria, self.integration.orm, ) - return count_from_filtered_select(filtered_statement) - def paginated_select( + def _sorted_statement( self, + filtered_statement: SQLAlchemySelect, criteria: RequestCriteria, - ) -> SQLAlchemyPaginatedSelect: - """Builds list and count statements for the configured model. + ) -> SQLAlchemySelect: + """Applies sort and page semantics to a filtered statement. Returns: - Paired list and count SQLAlchemy statements. + The filtered statement with sort and page semantics applied. """ - validated_criteria = require_request_criteria(criteria) - filtered_statement = apply_query_with_orm( - self._base_statement(), + return apply_sort_and_page_with_orm( + filtered_statement, self.model, - validated_criteria, + criteria, self.integration.orm, ) + + def _paginated_bundle( + self, + filtered_statement: SQLAlchemySelect, + criteria: RequestCriteria, + ) -> SQLAlchemyPaginatedSelect: + """Builds the list/count bundle from one filtered statement. + + Returns: + The paired list and count statements. + """ return SQLAlchemyPaginatedSelect( - statement=apply_sort_and_page_with_orm( + statement=self._sorted_statement( filtered_statement, - self.model, - validated_criteria, - self.integration.orm, + criteria, ), count_statement=count_from_filtered_select(filtered_statement), ) + def count_select(self, criteria: RequestCriteria) -> SQLAlchemySelect: + """Builds a count select for the configured resource model. + + Returns: + A count SQLAlchemy select statement. + """ + validated_criteria = require_request_criteria(criteria) + filtered_statement = self._filtered_statement(validated_criteria) + return count_from_filtered_select(filtered_statement) + + def paginated_select( + self, + criteria: RequestCriteria, + ) -> SQLAlchemyPaginatedSelect: + """Builds list and count statements for the configured model. + + Returns: + Paired list and count SQLAlchemy statements. + """ + validated_criteria = require_request_criteria(criteria) + filtered_statement = self._filtered_statement(validated_criteria) + return self._paginated_bundle(filtered_statement, validated_criteria) + def select_dependency(self) -> Callable[..., SQLAlchemySelect]: """Returns a FastAPI dependency yielding a filtered select. @@ -270,20 +288,14 @@ def select_dependency(self) -> Callable[..., SQLAlchemySelect]: def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): - filtered_statement = apply_query_with_orm( - self._base_statement(), - self.model, - validated_criteria, - self.integration.orm, + filtered_statement = self._filtered_statement( + criteria, ) with sort_backend_http_errors(self.criteria_config): - return apply_sort_and_page_with_orm( + return self._sorted_statement( filtered_statement, - self.model, - validated_criteria, - self.integration.orm, + criteria, ) self._select_dependency = dependency @@ -328,13 +340,9 @@ def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemySelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): - filtered_statement = apply_query_with_orm( - self._base_statement(), - self.model, - validated_criteria, - self.integration.orm, + filtered_statement = self._filtered_statement( + criteria, ) return count_from_filtered_select(filtered_statement) @@ -359,25 +367,14 @@ def paginated_select_dependency( def dependency( criteria: RequestCriteria = Depends(criteria_dependency), ) -> SQLAlchemyPaginatedSelect: - validated_criteria = require_request_criteria(criteria) with query_backend_http_errors(self.criteria_config): - filtered_statement = apply_query_with_orm( - self._base_statement(), - self.model, - validated_criteria, - self.integration.orm, + filtered_statement = self._filtered_statement( + criteria, ) with sort_backend_http_errors(self.criteria_config): - return SQLAlchemyPaginatedSelect( - statement=apply_sort_and_page_with_orm( - filtered_statement, - self.model, - validated_criteria, - self.integration.orm, - ), - count_statement=count_from_filtered_select( - filtered_statement, - ), + return self._paginated_bundle( + filtered_statement, + criteria, ) self._paginated_select_dependency = dependency diff --git a/src/pyrsql/orms/__init__.py b/src/pyrsql/orms/__init__.py index ff091f4..9ed9fee 100644 --- a/src/pyrsql/orms/__init__.py +++ b/src/pyrsql/orms/__init__.py @@ -5,6 +5,7 @@ CompiledPageRequest, CompiledQuery, CompiledSort, + ORMError, ) __all__ = [ @@ -12,4 +13,5 @@ "CompiledPageRequest", "CompiledQuery", "CompiledSort", + "ORMError", ] diff --git a/src/pyrsql/orms/base.py b/src/pyrsql/orms/base.py index a225d8e..2e0674d 100644 --- a/src/pyrsql/orms/base.py +++ b/src/pyrsql/orms/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, ClassVar, TypeAlias from pyrsql.core.compiler import CompiledArtifact @@ -17,6 +17,12 @@ CompiledPageRequest: TypeAlias = CompiledArtifact +class ORMError(ValueError): + """Base exception for ORM backend failures.""" + + code: ClassVar[str] = "orm_error" + + class ORM(ABC): """Abstract ORM contract used by the pyrsql public API.""" diff --git a/src/pyrsql/orms/sqlalchemy/coercion.py b/src/pyrsql/orms/sqlalchemy/coercion.py index 9378cd8..b536a19 100644 --- a/src/pyrsql/orms/sqlalchemy/coercion.py +++ b/src/pyrsql/orms/sqlalchemy/coercion.py @@ -103,7 +103,7 @@ def _coerce_with_registry( SQLAlchemyORMError: If no registry is available or conversion fails. """ - active_registry = registry or self._registry + active_registry = self._registry if registry is None else registry if active_registry is None: raise SQLAlchemyORMError( "SQLAlchemyValueCoercer requires a conversion registry.", diff --git a/src/pyrsql/orms/sqlalchemy/errors.py b/src/pyrsql/orms/sqlalchemy/errors.py index 0c7f1fc..fcc55d4 100644 --- a/src/pyrsql/orms/sqlalchemy/errors.py +++ b/src/pyrsql/orms/sqlalchemy/errors.py @@ -1,17 +1,29 @@ """SQLAlchemy ORM-specific exceptions.""" +from typing import ClassVar -class SQLAlchemyORMError(ValueError): +from pyrsql.orms.base import ORMError + + +class SQLAlchemyORMError(ORMError): """Base exception for SQLAlchemy ORM failures.""" + code: ClassVar[str] = "sqlalchemy_orm_error" + class SQLAlchemyModelInspectionError(SQLAlchemyORMError): """Raised when a model cannot be inspected as a mapped class.""" + code: ClassVar[str] = "sqlalchemy_model_inspection_error" + class SQLAlchemyPathResolutionError(SQLAlchemyORMError): """Raised when a bound field path cannot be resolved.""" + code: ClassVar[str] = "sqlalchemy_path_resolution_error" + class SQLAlchemyJSONSupportError(SQLAlchemyORMError): """Raised when JSON/JSONB translation cannot be completed.""" + + code: ClassVar[str] = "sqlalchemy_json_support_error" diff --git a/src/pyrsql/orms/sqlalchemy/json_path.py b/src/pyrsql/orms/sqlalchemy/json_path.py index ea0806f..71ddf53 100644 --- a/src/pyrsql/orms/sqlalchemy/json_path.py +++ b/src/pyrsql/orms/sqlalchemy/json_path.py @@ -180,7 +180,9 @@ def build_filter_expression( Returns: A PostgreSQL ``jsonb_path_exists`` predicate. """ - active_options = options or DEFAULT_JSON_OPTIONS + active_options = ( + DEFAULT_JSON_OPTIONS if options is None else options + ) function_call = self._build_filter_call( comparison, options=active_options, @@ -273,7 +275,9 @@ def build_sort_expression( Returns: The SQLAlchemy expression used for JSON sorting. """ - active_options = options or DEFAULT_JSON_OPTIONS + active_options = ( + DEFAULT_JSON_OPTIONS if options is None else options + ) jsonb_column = cast( "ColumnElement[Any]", sa.cast(column, postgresql.JSONB), diff --git a/src/pyrsql/orms/sqlalchemy/orm.py b/src/pyrsql/orms/sqlalchemy/orm.py index 44c0e7e..cbbc356 100644 --- a/src/pyrsql/orms/sqlalchemy/orm.py +++ b/src/pyrsql/orms/sqlalchemy/orm.py @@ -41,7 +41,11 @@ def __init__( translator=translator, custom_predicates=custom_predicates, ) - self._sort_translator = sort_translator or SQLAlchemySortTranslator() + self._sort_translator = ( + SQLAlchemySortTranslator() + if sort_translator is None + else sort_translator + ) @staticmethod def _build_translator( diff --git a/src/pyrsql/orms/sqlalchemy/resolver.py b/src/pyrsql/orms/sqlalchemy/resolver.py index 66d0ee5..431e755 100644 --- a/src/pyrsql/orms/sqlalchemy/resolver.py +++ b/src/pyrsql/orms/sqlalchemy/resolver.py @@ -42,7 +42,9 @@ def __init__( ) -> None: """Initializes the resolver with an optional shared inspector.""" self._cache_lock = Lock() - self._inspector = inspector or SQLAlchemyModelInspector() + self._inspector = ( + SQLAlchemyModelInspector() if inspector is None else inspector + ) self._default_resolution_cache: dict[ tuple[type[Any], str], SQLAlchemyResolvedPath, diff --git a/src/pyrsql/orms/sqlalchemy/sorter.py b/src/pyrsql/orms/sqlalchemy/sorter.py index 0516944..1dc68cf 100644 --- a/src/pyrsql/orms/sqlalchemy/sorter.py +++ b/src/pyrsql/orms/sqlalchemy/sorter.py @@ -60,9 +60,15 @@ def __init__( json_path_builder: SQLAlchemyJSONPathExpressionBuilder | None = None, ) -> None: """Initializes the translator with reusable helper collaborators.""" - self._path_resolver = path_resolver or SQLAlchemyPathResolver() + self._path_resolver = ( + SQLAlchemyPathResolver() + if path_resolver is None + else path_resolver + ) self._json_path_builder = ( - json_path_builder or SQLAlchemyJSONPathExpressionBuilder() + SQLAlchemyJSONPathExpressionBuilder() + if json_path_builder is None + else json_path_builder ) def translate( diff --git a/src/pyrsql/orms/sqlalchemy/translator.py b/src/pyrsql/orms/sqlalchemy/translator.py index 1bb5ead..9a81279 100644 --- a/src/pyrsql/orms/sqlalchemy/translator.py +++ b/src/pyrsql/orms/sqlalchemy/translator.py @@ -105,13 +105,23 @@ def __init__( json_path_builder: SQLAlchemyJSONPathExpressionBuilder | None = None, ) -> None: """Initializes the translator with reusable lowering collaborators.""" - self._path_resolver = path_resolver or SQLAlchemyPathResolver() - self._value_coercer = value_coercer or SQLAlchemyValueCoercer() + self._path_resolver = ( + SQLAlchemyPathResolver() + if path_resolver is None + else path_resolver + ) + self._value_coercer = ( + SQLAlchemyValueCoercer() + if value_coercer is None + else value_coercer + ) self._json_path_builder = ( - json_path_builder or SQLAlchemyJSONPathExpressionBuilder() + SQLAlchemyJSONPathExpressionBuilder() + if json_path_builder is None + else json_path_builder ) self._orm_custom_predicates = MappingProxyType( - dict(custom_predicates or {}), + {} if custom_predicates is None else dict(custom_predicates), ) def translate( diff --git a/src/pyrsql/parsing/diagnostics.py b/src/pyrsql/parsing/diagnostics.py index 4c64b41..86259f0 100644 --- a/src/pyrsql/parsing/diagnostics.py +++ b/src/pyrsql/parsing/diagnostics.py @@ -2,28 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pyrsql.core.source_issues import SourceDiagnostic -import msgspec -if TYPE_CHECKING: - from pyrsql.parsing.source import SourceSpan - - -class ParseDiagnostic(msgspec.Struct, frozen=True, gc=False, kw_only=True): +class ParseDiagnostic(SourceDiagnostic): """A structured parsing diagnostic.""" - - code: str - message: str - span: SourceSpan - - def __str__(self) -> str: - """Formats the diagnostic with source position data. - - Returns: - The formatted diagnostic string. - """ - return ( - f"[{self.code}] {self.message} at index {self.span.start.index} " - f"(line {self.span.start.line}, column {self.span.start.column})" - ) diff --git a/src/pyrsql/parsing/errors.py b/src/pyrsql/parsing/errors.py index 639dd29..52dacbf 100644 --- a/src/pyrsql/parsing/errors.py +++ b/src/pyrsql/parsing/errors.py @@ -2,46 +2,17 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar +from pyrsql.core.source_issues import SourceError from pyrsql.parsing.diagnostics import ParseDiagnostic -if TYPE_CHECKING: - from pyrsql.parsing.source import SourceSpan - -@dataclass(frozen=True, slots=True) -class ParseError(ValueError): +class ParseError(SourceError): """Base exception for parsing failures.""" - message: str - span: SourceSpan code: ClassVar[str] = "parse_error" - - @property - def diagnostic(self) -> ParseDiagnostic: - """Returns the structured diagnostic for this error. - - Returns: - The structured parse diagnostic. - """ - return ParseDiagnostic( - code=self.code, - message=self.message, - span=self.span, - ) - - def __str__(self) -> str: - """Formats the parse error with source location data. - - Returns: - The formatted parse error string. - """ - return ( - f"[{self.code}] {self.message} at index {self.span.start.index} " - f"(line {self.span.start.line}, column {self.span.start.column})" - ) + diagnostic_type: ClassVar[type[ParseDiagnostic]] = ParseDiagnostic class LexError(ParseError): diff --git a/src/pyrsql/parsing/lexer.py b/src/pyrsql/parsing/lexer.py index feb4b61..53aad83 100644 --- a/src/pyrsql/parsing/lexer.py +++ b/src/pyrsql/parsing/lexer.py @@ -5,7 +5,10 @@ from typing import TYPE_CHECKING from pyrsql.parsing.errors import LexError -from pyrsql.parsing.limits import DEFAULT_PARSE_LIMITS +from pyrsql.parsing.normalization import ( + normalize_operator_registry, + normalize_parse_limits, +) from pyrsql.parsing.operators import DEFAULT_OPERATOR_REGISTRY from pyrsql.parsing.source import SourcePosition, SourceSpan, SourceText from pyrsql.parsing.tokens import Token, TokenKind @@ -28,9 +31,10 @@ def __init__( """Initializes the lexer with source text and parser limits.""" self._text = self._normalize_source(source).text self._length = len(self._text) - self._limits = self._normalize_limits(limits) - self._operator_registry = self._normalize_operator_registry( + self._limits = normalize_parse_limits(limits, owner_type=type(self)) + self._operator_registry = normalize_operator_registry( operator_registry, + owner_type=type(self), ) self._index = 0 self._line = 1 @@ -53,40 +57,6 @@ def _normalize_source(source: str | SourceText) -> SourceText: raise TypeError("Lexer source must be a string or SourceText.") return SourceText(text=source) - @staticmethod - def _normalize_limits(limits: ParseLimits | None) -> ParseLimits: - """Normalizes optional lexer limits. - - Returns: - The provided limits, or the shared defaults. - - Raises: - TypeError: If ``limits`` is not a ``ParseLimits`` instance. - """ - if limits is None: - return DEFAULT_PARSE_LIMITS - if not isinstance(limits, type(DEFAULT_PARSE_LIMITS)): - raise TypeError("Lexer limits must be a ParseLimits instance.") - return limits - - @staticmethod - def _normalize_operator_registry( - operator_registry: OperatorRegistry, - ) -> OperatorRegistry: - """Normalizes the operator registry dependency. - - Returns: - The validated operator registry instance. - - Raises: - TypeError: If the registry is not an ``OperatorRegistry``. - """ - if not isinstance(operator_registry, type(DEFAULT_OPERATOR_REGISTRY)): - raise TypeError( - "Lexer operator_registry must be an OperatorRegistry instance.", - ) - return operator_registry - @property def limits(self) -> ParseLimits: """Returns the configured parser safety limits. diff --git a/src/pyrsql/parsing/normalization.py b/src/pyrsql/parsing/normalization.py new file mode 100644 index 0000000..dd903c2 --- /dev/null +++ b/src/pyrsql/parsing/normalization.py @@ -0,0 +1,51 @@ +"""Shared runtime normalization helpers for parsing components.""" + +from __future__ import annotations + +from pyrsql.parsing.limits import DEFAULT_PARSE_LIMITS, ParseLimits +from pyrsql.parsing.operators import ( + OperatorRegistry, +) + + +def normalize_parse_limits( + limits: ParseLimits | None, + *, + owner_type: type[object], +) -> ParseLimits: + """Normalizes optional parser limits. + + Returns: + The provided limits, or the shared defaults. + + Raises: + TypeError: If ``limits`` is not a ``ParseLimits`` instance. + """ + if limits is None: + return DEFAULT_PARSE_LIMITS + if not isinstance(limits, ParseLimits): + raise TypeError( + f"{owner_type.__name__} limits must be a ParseLimits instance.", + ) + return limits + + +def normalize_operator_registry( + operator_registry: OperatorRegistry, + *, + owner_type: type[object], +) -> OperatorRegistry: + """Normalizes the operator registry dependency. + + Returns: + The validated operator registry instance. + + Raises: + TypeError: If the registry is not an ``OperatorRegistry``. + """ + if not isinstance(operator_registry, OperatorRegistry): + raise TypeError( + f"{owner_type.__name__} operator_registry must be an " + "OperatorRegistry instance.", + ) + return operator_registry diff --git a/src/pyrsql/parsing/parser.py b/src/pyrsql/parsing/parser.py index 2ca23a9..ca02c82 100644 --- a/src/pyrsql/parsing/parser.py +++ b/src/pyrsql/parsing/parser.py @@ -12,7 +12,10 @@ ) from pyrsql.parsing.errors import ParseError from pyrsql.parsing.lexer import Lexer -from pyrsql.parsing.limits import DEFAULT_PARSE_LIMITS +from pyrsql.parsing.normalization import ( + normalize_operator_registry, + normalize_parse_limits, +) from pyrsql.parsing.operators import ( DEFAULT_OPERATOR_REGISTRY, ) @@ -60,9 +63,10 @@ def __init__( operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY, ) -> None: """Initializes the parser with raw source text and limits.""" - self._limits = self._normalize_limits(limits) - self._operator_registry = self._normalize_operator_registry( + self._limits = normalize_parse_limits(limits, owner_type=type(self)) + self._operator_registry = normalize_operator_registry( operator_registry, + owner_type=type(self), ) self._tokens = Lexer( source, @@ -73,41 +77,6 @@ def __init__( self._index = 0 self._node_count = 0 - @staticmethod - def _normalize_limits(limits: ParseLimits | None) -> ParseLimits: - """Normalizes optional parser limits. - - Returns: - The provided limits, or the shared defaults. - - Raises: - TypeError: If ``limits`` is not a ``ParseLimits`` instance. - """ - if limits is None: - return DEFAULT_PARSE_LIMITS - if not isinstance(limits, type(DEFAULT_PARSE_LIMITS)): - raise TypeError("Parser limits must be a ParseLimits instance.") - return limits - - @staticmethod - def _normalize_operator_registry( - operator_registry: OperatorRegistry, - ) -> OperatorRegistry: - """Normalizes the operator registry dependency. - - Returns: - The validated operator registry instance. - - Raises: - TypeError: If the registry is not an ``OperatorRegistry``. - """ - if not isinstance(operator_registry, type(DEFAULT_OPERATOR_REGISTRY)): - raise TypeError( - "Parser operator_registry must be an " - "OperatorRegistry instance.", - ) - return operator_registry - @property def limits(self) -> ParseLimits: """Returns the configured parser safety limits. diff --git a/src/pyrsql/semantic/diagnostics.py b/src/pyrsql/semantic/diagnostics.py index c34f364..2d559c4 100644 --- a/src/pyrsql/semantic/diagnostics.py +++ b/src/pyrsql/semantic/diagnostics.py @@ -2,28 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pyrsql.core.source_issues import SourceDiagnostic -import msgspec -if TYPE_CHECKING: - from pyrsql.parsing.source import SourceSpan - - -class SemanticDiagnostic(msgspec.Struct, frozen=True, gc=False, kw_only=True): +class SemanticDiagnostic(SourceDiagnostic): """A structured semantic diagnostic.""" - - code: str - message: str - span: SourceSpan - - def __str__(self) -> str: - """Formats the diagnostic with source position data. - - Returns: - The formatted diagnostic string. - """ - return ( - f"[{self.code}] {self.message} at index {self.span.start.index} " - f"(line {self.span.start.line}, column {self.span.start.column})" - ) diff --git a/src/pyrsql/semantic/errors.py b/src/pyrsql/semantic/errors.py index 7543f0b..0fbbcc1 100644 --- a/src/pyrsql/semantic/errors.py +++ b/src/pyrsql/semantic/errors.py @@ -2,70 +2,37 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar +from pyrsql.core.source_issues import SourceError from pyrsql.semantic.diagnostics import SemanticDiagnostic -if TYPE_CHECKING: - from pyrsql.parsing.source import SourceSpan - -@dataclass(frozen=True, slots=True) -class SemanticError(ValueError): +class SemanticError(SourceError): """Base exception for semantic analysis failures.""" - message: str - span: SourceSpan code: ClassVar[str] = "semantic_error" - - @property - def diagnostic(self) -> SemanticDiagnostic: - """Returns the structured diagnostic for this error. - - Returns: - The structured semantic diagnostic. - """ - return SemanticDiagnostic( - code=self.code, - message=self.message, - span=self.span, - ) - - def __str__(self) -> str: - """Formats semantic errors with source position data. - - Returns: - The formatted semantic error string. - """ - return ( - f"[{self.code}] {self.message} at index {self.span.start.index} " - f"(line {self.span.start.line}, column {self.span.start.column})" - ) + diagnostic_type: ClassVar[type[SemanticDiagnostic]] = SemanticDiagnostic -@dataclass(frozen=True, slots=True) class FieldNotWhitelistedError(SemanticError): """Raised when a selector is not allowed by the whitelist.""" code: ClassVar[str] = "field_not_whitelisted" -@dataclass(frozen=True, slots=True) class FieldBlacklistedError(SemanticError): """Raised when a selector is blocked by the blacklist.""" code: ClassVar[str] = "field_blacklisted" -@dataclass(frozen=True, slots=True) class FunctionNotWhitelistedError(SemanticError): """Raised when a function selector is not allowed by the whitelist.""" code: ClassVar[str] = "function_not_whitelisted" -@dataclass(frozen=True, slots=True) class FunctionBlacklistedError(SemanticError): """Raised when a function selector is blocked by the blacklist.""" diff --git a/tests/functional/fastapi_adapter/test_async_dependency.py b/tests/functional/fastapi_adapter/test_async_dependency.py index 269785a..6766860 100644 --- a/tests/functional/fastapi_adapter/test_async_dependency.py +++ b/tests/functional/fastapi_adapter/test_async_dependency.py @@ -109,6 +109,6 @@ async def list_items( assert response.status_code == 422 payload = response.json()["detail"] - assert payload["type"] == "query_semantic_error" + assert payload["type"] == "urn:pyrsql:problem:query-semantic-error" assert payload["errors"][0]["code"] == "field_not_whitelisted" assert payload["errors"][0]["field"] == "password" diff --git a/tests/functional/fastapi_adapter/test_dependency.py b/tests/functional/fastapi_adapter/test_dependency.py index 45ee2c0..b174113 100644 --- a/tests/functional/fastapi_adapter/test_dependency.py +++ b/tests/functional/fastapi_adapter/test_dependency.py @@ -94,8 +94,8 @@ def list_items( } -def test_dependency_translates_query_parse_errors_to_http_422() -> None: - """Maps query parsing failures to a stable HTTP error payload.""" +def test_dependency_translates_query_parse_errors_to_http_400() -> None: + """Maps query parsing failures to a stable HTTP 400 error payload.""" app = FastAPI() dependency = criteria_dependency() @@ -107,10 +107,11 @@ def list_items( response = TestClient(app).get("/items", params={"filter": "name=="}) - assert response.status_code == 422 + assert response.status_code == 400 payload = response.json()["detail"] assert payload["parameter"] == "filter" - assert payload["type"] == "query_parse_error" + assert payload["type"] == "urn:pyrsql:problem:query-parse-error" + assert payload["title"] == "Query parse error" assert payload["errors"][0]["code"] == "parse_error" assert payload["errors"][0]["location"] == { "index": 4, @@ -141,7 +142,7 @@ def list_items( assert response.status_code == 422 payload = response.json()["detail"] - assert payload["type"] == "query_semantic_error" + assert payload["type"] == "urn:pyrsql:problem:query-semantic-error" assert payload["errors"][0]["code"] == "field_not_whitelisted" assert payload["errors"][0]["field"] == "password" assert payload["errors"][0]["location"] == { diff --git a/tests/security/test_fastapi_sqlalchemy_error_sanitization.py b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py index f6f5c7b..b89e966 100644 --- a/tests/security/test_fastapi_sqlalchemy_error_sanitization.py +++ b/tests/security/test_fastapi_sqlalchemy_error_sanitization.py @@ -24,6 +24,8 @@ pytest.mark.sqlalchemy, ] +QUERY_PARSE_TYPE = "urn:pyrsql:problem:query-parse-error" + def test_backend_query_errors_do_not_leak_stack_traces( integration_app_factory: Callable[..., TestClient], @@ -66,8 +68,8 @@ def test_query_parse_errors_do_not_leak_internal_details( params={"filter": "name=='demo'"}, ) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert_response_hides_internal_error_details(response) @@ -94,6 +96,6 @@ def test_composed_hostile_request_fails_cleanly_at_first_limit( }, ) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert_response_hides_internal_error_details(response) diff --git a/tests/security/test_fastapi_sqlalchemy_hpp.py b/tests/security/test_fastapi_sqlalchemy_hpp.py index 820bc26..347872b 100644 --- a/tests/security/test_fastapi_sqlalchemy_hpp.py +++ b/tests/security/test_fastapi_sqlalchemy_hpp.py @@ -20,6 +20,9 @@ pytest.mark.sqlalchemy, ] +QUERY_BACKEND_TYPE = "urn:pyrsql:problem:query-backend-error" +SORT_BACKEND_TYPE = "urn:pyrsql:problem:sort-backend-error" + def test_duplicate_filter_parameters_use_last_value_and_still_validate( integration_app_factory: Callable[..., TestClient], @@ -31,7 +34,7 @@ def test_duplicate_filter_parameters_use_last_value_and_still_validate( ) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_backend_error" + assert response.json()["detail"]["type"] == QUERY_BACKEND_TYPE assert response.json()["detail"]["errors"][0]["field"] == "password" @@ -45,7 +48,7 @@ def test_duplicate_sort_parameters_use_last_value_and_still_validate( ) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_backend_error" + assert response.json()["detail"]["type"] == SORT_BACKEND_TYPE assert response.json()["detail"]["errors"][0]["field"] == ( "DROP TABLE users" ) diff --git a/tests/security/test_fastapi_sqlalchemy_injection.py b/tests/security/test_fastapi_sqlalchemy_injection.py index 1ed389e..347f825 100644 --- a/tests/security/test_fastapi_sqlalchemy_injection.py +++ b/tests/security/test_fastapi_sqlalchemy_injection.py @@ -30,7 +30,7 @@ def test_integration_returns_http_422_for_malicious_sort_field( assert response.status_code == 422 assert '"parameter":"sort"' in response.text - assert '"type":"sort_backend_error"' in response.text + assert '"type":"urn:pyrsql:problem:sort-backend-error"' in response.text assert '"field":"DROP TABLE users"' in response.text @@ -45,5 +45,5 @@ def test_integration_returns_http_422_for_malicious_filter_field( assert response.status_code == 422 assert '"parameter":"filter"' in response.text - assert '"type":"query_backend_error"' in response.text + assert '"type":"urn:pyrsql:problem:query-backend-error"' in response.text assert '"field":"password"' in response.text diff --git a/tests/security/test_fastapi_sqlalchemy_limits.py b/tests/security/test_fastapi_sqlalchemy_limits.py index 1c839e9..a61fb86 100644 --- a/tests/security/test_fastapi_sqlalchemy_limits.py +++ b/tests/security/test_fastapi_sqlalchemy_limits.py @@ -23,6 +23,9 @@ pytest.mark.sqlalchemy, ] +QUERY_PARSE_TYPE = "urn:pyrsql:problem:query-parse-error" +SORT_PARSE_TYPE = "urn:pyrsql:problem:sort-parse-error" + def test_integration_rejects_filter_exceeding_query_length_limit( integration_app_factory: Callable[..., TestClient], @@ -38,8 +41,8 @@ def test_integration_rejects_filter_exceeding_query_length_limit( response = client.get("/users", params={"filter": "name=='demo'"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "lex_error" @@ -57,8 +60,8 @@ def test_integration_rejects_filter_exceeding_selector_length_limit( response = client.get("/users", params={"filter": "company.name==demo"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "lex_error" @@ -76,8 +79,8 @@ def test_integration_rejects_filter_exceeding_argument_length_limit( response = client.get("/users", params={"filter": "name=='demo'"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "lex_error" @@ -95,8 +98,8 @@ def test_integration_rejects_filter_with_too_many_list_arguments( response = client.get("/users", params={"filter": "id=in=(1,2,3)"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "parse_error" @@ -114,8 +117,8 @@ def test_integration_rejects_filter_exceeding_expression_depth_limit( response = client.get("/users", params={"filter": "((name==demo))"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "parse_error" @@ -136,8 +139,8 @@ def test_integration_rejects_filter_exceeding_node_count_limit( params={"filter": "name==demo;company.name==acme"}, ) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == QUERY_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "parse_error" @@ -155,8 +158,8 @@ def test_integration_rejects_sort_exceeding_total_length_limit( response = client.get("/users", params={"sort": "company.name,desc"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == SORT_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" @@ -174,8 +177,8 @@ def test_integration_rejects_sort_with_too_many_fields( response = client.get("/users", params={"sort": "name;company.name"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == SORT_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" @@ -193,8 +196,8 @@ def test_integration_rejects_sort_exceeding_field_path_length_limit( response = client.get("/users", params={"sort": "company.name,asc"}) - assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_parse_error" + assert response.status_code == 400 + assert response.json()["detail"]["type"] == SORT_PARSE_TYPE assert response.json()["detail"]["errors"][0]["code"] == "sort_parse_error" diff --git a/tests/security/test_fastapi_sqlalchemy_policies.py b/tests/security/test_fastapi_sqlalchemy_policies.py index 865fc89..a010cb3 100644 --- a/tests/security/test_fastapi_sqlalchemy_policies.py +++ b/tests/security/test_fastapi_sqlalchemy_policies.py @@ -21,6 +21,9 @@ pytest.mark.sqlalchemy, ] +QUERY_SEMANTIC_TYPE = "urn:pyrsql:problem:query-semantic-error" +SORT_SEMANTIC_TYPE = "urn:pyrsql:problem:sort-semantic-error" + def test_integration_rejects_filter_field_outside_whitelist( integration_app_factory: Callable[..., TestClient], @@ -35,7 +38,7 @@ def test_integration_rejects_filter_field_outside_whitelist( response = client.get("/users", params={"filter": "company.name==acme"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["type"] == QUERY_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "field_not_whitelisted" ) @@ -54,7 +57,7 @@ def test_integration_rejects_filter_field_in_blacklist( response = client.get("/users", params={"filter": "name==demo"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["type"] == QUERY_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "field_blacklisted" ) @@ -73,7 +76,7 @@ def test_integration_rejects_filter_function_outside_whitelist( response = client.get("/users", params={"filter": "@upper[name]==DEMO"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["type"] == QUERY_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "function_not_whitelisted" ) @@ -95,7 +98,7 @@ def test_integration_rejects_filter_function_in_blacklist( response = client.get("/users", params={"filter": "@upper[name]==DEMO"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["type"] == QUERY_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "function_blacklisted" ) @@ -114,7 +117,7 @@ def test_integration_rejects_sort_field_outside_whitelist( response = client.get("/users", params={"sort": "company.name,asc"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["type"] == SORT_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "sort_field_not_whitelisted" ) @@ -133,7 +136,7 @@ def test_integration_rejects_sort_field_in_blacklist( response = client.get("/users", params={"sort": "name,asc"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["type"] == SORT_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "sort_field_blacklisted" ) @@ -152,7 +155,7 @@ def test_integration_rejects_sort_function_outside_whitelist( response = client.get("/users", params={"sort": "@upper[name],asc"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["type"] == SORT_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "sort_function_not_whitelisted" ) @@ -174,7 +177,7 @@ def test_integration_rejects_sort_function_in_blacklist( response = client.get("/users", params={"sort": "@upper[name],asc"}) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["type"] == SORT_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "sort_function_blacklisted" ) diff --git a/tests/security/test_fastapi_sqlalchemy_resource_security.py b/tests/security/test_fastapi_sqlalchemy_resource_security.py index 433bc11..4654e3c 100644 --- a/tests/security/test_fastapi_sqlalchemy_resource_security.py +++ b/tests/security/test_fastapi_sqlalchemy_resource_security.py @@ -18,6 +18,9 @@ pytest.mark.sqlalchemy, ] +QUERY_SEMANTIC_TYPE = "urn:pyrsql:problem:query-semantic-error" +SORT_SEMANTIC_TYPE = "urn:pyrsql:problem:sort-semantic-error" + def test_resource_rejects_filter_field_outside_declared_allowlist( resource_app_factory: Callable[..., TestClient], @@ -29,7 +32,7 @@ def test_resource_rejects_filter_field_outside_declared_allowlist( ) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "query_semantic_error" + assert response.json()["detail"]["type"] == QUERY_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "field_not_whitelisted" ) @@ -45,7 +48,7 @@ def test_resource_rejects_sort_field_outside_declared_allowlist( ) assert response.status_code == 422 - assert response.json()["detail"]["type"] == "sort_semantic_error" + assert response.json()["detail"]["type"] == SORT_SEMANTIC_TYPE assert response.json()["detail"]["errors"][0]["code"] == ( "sort_field_not_whitelisted" ) diff --git a/tests/unit/adapters/fastapi_adapter/test_config.py b/tests/unit/adapters/fastapi_adapter/test_config.py index a2561b3..9f3000a 100644 --- a/tests/unit/adapters/fastapi_adapter/test_config.py +++ b/tests/unit/adapters/fastapi_adapter/test_config.py @@ -26,13 +26,20 @@ {"filter_parameter": " filter "}, id="outer-whitespace", ), + pytest.param( + {"filter_parameter": 123}, + id="non-string-parameter-name", + ), ], ) def test_config_rejects_invalid_parameter_names( kwargs: dict[str, object], ) -> None: """Rejects duplicate aliases and names with outer whitespace.""" - with pytest.raises(ValueError, match=r"(?i)parameter|whitespace|unique"): + with pytest.raises( + (TypeError, ValueError), + match=r"(?i)parameter|whitespace|unique|string", + ): FastAPICriteriaConfig(**cast("Any", kwargs)) @@ -49,6 +56,11 @@ def test_config_rejects_invalid_parameter_names( r"max|page size|exceed", id="default-page-size-above-max", ), + pytest.param( + {"max_page_size": True}, + r"max_page_size|integer", + id="bool-max-page-size", + ), pytest.param( {"query_options": "invalid"}, r"query_options", diff --git a/tests/unit/adapters/fastapi_adapter/test_criteria.py b/tests/unit/adapters/fastapi_adapter/test_criteria.py index 345fb57..64f63e0 100644 --- a/tests/unit/adapters/fastapi_adapter/test_criteria.py +++ b/tests/unit/adapters/fastapi_adapter/test_criteria.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING from unittest.mock import Mock import pytest @@ -69,27 +69,6 @@ def test_request_criteria_reports_empty_state(query_stub: Query) -> None: assert RequestCriteria(query=query_stub).is_empty is False -@pytest.mark.parametrize( - ("kwargs", "pattern"), - [ - pytest.param({"query": "invalid"}, r"query", id="invalid-query"), - pytest.param({"sort": "invalid"}, r"sort", id="invalid-sort"), - pytest.param( - {"page_request": "invalid"}, - r"page_request", - id="invalid-page-request", - ), - ], -) -def test_request_criteria_rejects_invalid_member_types( - kwargs: dict[str, object], - pattern: str, -) -> None: - """Rejects criteria payloads with invalid runtime types.""" - with pytest.raises(TypeError, match=pattern): - RequestCriteria(**cast("Any", kwargs)) - - def test_request_criteria_applies_query_sort_and_page_in_order( query_stub: Query, sort_stub: Sort, diff --git a/tests/unit/adapters/fastapi_adapter/test_errors.py b/tests/unit/adapters/fastapi_adapter/test_errors.py new file mode 100644 index 0000000..e345f2f --- /dev/null +++ b/tests/unit/adapters/fastapi_adapter/test_errors.py @@ -0,0 +1,30 @@ +"""Unit tests for FastAPI adapter error payload helpers.""" + +from __future__ import annotations + +from pyrsql.adapters.fastapi.errors import FastAPIAdapterErrorPayload +from pyrsql.orms.sqlalchemy.errors import SQLAlchemyPathResolutionError + + +def test_backend_error_payload_preserves_http_detail_shape() -> None: + """Builds the expected HTTP detail mapping for backend errors.""" + payload = FastAPIAdapterErrorPayload.from_backend_error( + "filter", + error_type="query_backend_error", + error=SQLAlchemyPathResolutionError("Field 'password' is not allowed."), + ) + + assert payload.to_http_detail() == { + "type": "urn:pyrsql:problem:query-backend-error", + "title": "Query backend error", + "parameter": "filter", + "detail": "Field 'password' is not allowed.", + "errors": [ + { + "code": "sqlalchemy_path_resolution_error", + "detail": "Field 'password' is not allowed.", + "field": "password", + "location": None, + }, + ], + } diff --git a/tests/unit/core/test_custom.py b/tests/unit/core/test_custom.py index 052df1e..0051e4f 100644 --- a/tests/unit/core/test_custom.py +++ b/tests/unit/core/test_custom.py @@ -2,8 +2,6 @@ from __future__ import annotations -import pytest - from pyrsql.core.custom import CustomPredicateDefinition from pyrsql.parsing.operators import ComparisonOperator @@ -22,35 +20,3 @@ def test_custom_predicate_definition_stores_valid_runtime_contract() -> None: assert definition.operator.name == "all_match" assert definition.argument_type is str - - -@pytest.mark.parametrize( - ("kwargs", "pattern"), - [ - pytest.param( - {"operator": object(), "argument_type": str}, - "ComparisonOperator", - id="invalid-operator", - ), - pytest.param( - { - "operator": ComparisonOperator( - name="all_match", - spellings=("=all=",), - minimum_arguments=1, - maximum_arguments=1, - ), - "argument_type": "str", - }, - "runtime type", - id="invalid-argument-type", - ), - ], -) -def test_custom_predicate_definition_rejects_invalid_runtime_contract( - kwargs: dict[str, object], - pattern: str, -) -> None: - """Rejects invalid custom predicate contract inputs.""" - with pytest.raises(TypeError, match=pattern): - CustomPredicateDefinition(**kwargs) diff --git a/tests/unit/core/test_options.py b/tests/unit/core/test_options.py index f7c04a1..0175d27 100644 --- a/tests/unit/core/test_options.py +++ b/tests/unit/core/test_options.py @@ -222,6 +222,38 @@ def test_sort_options_are_normalized() -> None: assert options.join_hints["User.company"] is JoinHint.INNER +def test_query_options_with_field_whitelist_returns_updated_copy() -> None: + """Copies query options while replacing only the global whitelist.""" + options = QueryOptions( + distinct=True, + field_blacklist=frozenset({"blocked"}), + parse_limits=DEFAULT_PARSE_LIMITS, + ) + + updated = options.with_field_whitelist(frozenset({"name"})) + + assert updated is not options + assert updated.field_whitelist == frozenset({"name"}) + assert updated.field_blacklist == frozenset({"blocked"}) + assert updated.distinct is True + assert updated.parse_limits is DEFAULT_PARSE_LIMITS + + +def test_sort_options_with_field_whitelist_returns_updated_copy() -> None: + """Copies sort options while replacing only the global whitelist.""" + options = SortOptions( + field_blacklist=frozenset({"blocked"}), + sort_limits=DEFAULT_SORT_LIMITS, + ) + + updated = options.with_field_whitelist(frozenset({"name"})) + + assert updated is not options + assert updated.field_whitelist == frozenset({"name"}) + assert updated.field_blacklist == frozenset({"blocked"}) + assert updated.sort_limits is DEFAULT_SORT_LIMITS + + def test_sort_options_cache_derived_policy_objects() -> None: """Caches derived helper objects after normalization.""" options = SortOptions() diff --git a/tests/unit/integrations/fastapi_sqlalchemy/test_examples.py b/tests/unit/integrations/fastapi_sqlalchemy/test_examples.py new file mode 100644 index 0000000..5a8fa1c --- /dev/null +++ b/tests/unit/integrations/fastapi_sqlalchemy/test_examples.py @@ -0,0 +1,99 @@ +"""Tests for FastAPI + SQLAlchemy OpenAPI example generation.""" + +from __future__ import annotations + +from enum import IntEnum + +from sqlalchemy import Boolean, Column, Date, DateTime, Integer, String +from sqlalchemy.orm import DeclarativeBase + +from pyrsql.adapters.fastapi import FastAPICriteriaConfig +from pyrsql.core.options import QueryOptions +from pyrsql.integrations.fastapi import FastAPISQLAlchemyIntegration +from pyrsql.integrations.fastapi.sqlalchemy.examples import ( + build_filter_examples, + filter_example_value, +) + + +class Base(DeclarativeBase): + """Base model for example-generation tests.""" + + +class ExampleModel(Base): + """Mapped model with representative scalar field types.""" + + __tablename__ = "example_models" + + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + is_active = Column(Boolean, nullable=False) + birthday = Column(Date, nullable=False) + created_at = Column(DateTime, nullable=False) + + +class Status(IntEnum): + """Integer-backed enum used to validate example generation.""" + + ACTIVE = 7 + + +def test_build_filter_examples_uses_resolved_python_types() -> None: + """Generates filter examples from resolved SQLAlchemy field types.""" + assert ( + build_filter_examples(ExampleModel, {"id"})["filter_by_id"]["value"] + == "id==1" + ) + assert ( + build_filter_examples(ExampleModel, {"name"})["filter_by_name"][ + "value" + ] + == "name==demo" + ) + assert ( + build_filter_examples( + ExampleModel, + {"is_active"}, + )["filter_by_is_active"]["value"] + == "is_active==true" + ) + assert ( + build_filter_examples( + ExampleModel, + {"birthday"}, + )["filter_by_birthday"]["value"] + == "birthday==2026-01-01" + ) + assert ( + build_filter_examples( + ExampleModel, + {"created_at"}, + )["filter_by_created_at"]["value"] + == "created_at==2026-01-01T10:30:00" + ) + + +def test_resource_generates_filter_examples_for_field_aliases() -> None: + """Resolves example types through configured field aliases.""" + integration = FastAPISQLAlchemyIntegration( + criteria_config=FastAPICriteriaConfig( + query_options=QueryOptions(field_mapping={"public_id": "id"}), + ), + ) + + resource = integration.resource( + ExampleModel, + filterable_fields={"public_id"}, + ) + + assert ( + resource.criteria_config.filter_openapi_examples[ + "filter_by_public_id" + ]["value"] + == "public_id==1" + ) + + +def test_filter_example_value_prefers_enum_values_over_int_fallback() -> None: + """Uses the enum member value for IntEnum types.""" + assert filter_example_value(Status) == "7" diff --git a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py index 403b6d0..d0a909c 100644 --- a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py +++ b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py @@ -221,8 +221,8 @@ def test_integration_count_select_uses_filtered_select_only( Mock(return_value=filtered_statement), ) monkeypatch.setattr( - FastAPISQLAlchemyIntegration, - "_count_from_filtered_select", + integration_module, + "count_from_filtered_select", Mock(return_value=count_statement), ) @@ -252,8 +252,8 @@ def test_integration_paginated_select_builds_bundle_from_shared_filtered_select( Mock(return_value=list_statement), ) monkeypatch.setattr( - FastAPISQLAlchemyIntegration, - "_count_from_filtered_select", + integration_module, + "count_from_filtered_select", Mock(return_value=count_statement), ) @@ -570,13 +570,18 @@ def test_integration_rejects_invalid_request_criteria( method(User, cast("Any", "invalid")) -def test_paginated_select_rejects_invalid_statements() -> None: - """Rejects non-select statement payloads in the paginated bundle.""" - with pytest.raises(TypeError): - SQLAlchemyPaginatedSelect( - statement=cast("Any", "invalid"), - count_statement=select(User), - ) +def test_paginated_select_carries_list_and_count_statements() -> None: + """Carries the list and count statements without extra validation.""" + list_statement = select(User) + count_statement = select(User.id) + + bundle = SQLAlchemyPaginatedSelect( + statement=list_statement, + count_statement=count_statement, + ) + + assert bundle.statement is list_statement + assert bundle.count_statement is count_statement def test_resource_dependency_respects_explicit_max_page_size( @@ -586,3 +591,43 @@ def test_resource_dependency_respects_explicit_max_page_size( resource = integration.resource(User, max_page_size=50) assert resource.criteria_config.max_page_size == 50 + + +@pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param( + {"query_parameter_name": ""}, + "must not be empty", + id="blank-query-parameter-name", + ), + pytest.param( + {"sort_parameter_name": ""}, + "must not be empty", + id="blank-sort-parameter-name", + ), + pytest.param( + {"page_parameter_name": ""}, + "must not be empty", + id="blank-page-parameter-name", + ), + pytest.param( + {"size_parameter_name": ""}, + "must not be empty", + id="blank-size-parameter-name", + ), + pytest.param( + {"max_page_size": 0}, + "max_page_size", + id="invalid-max-page-size", + ), + ], +) +def test_resource_rejects_invalid_explicit_config_overrides( + integration: FastAPISQLAlchemyIntegration, + kwargs: dict[str, object], + pattern: str, +) -> None: + """Rejects explicit invalid resource config instead of falling back.""" + with pytest.raises((TypeError, ValueError), match=pattern): + integration.resource(User, **cast("Any", kwargs)) diff --git a/uv.lock b/uv.lock index 2968953..963402f 100644 --- a/uv.lock +++ b/uv.lock @@ -246,115 +246,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [package.optional-dependencies] @@ -520,11 +520,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -843,7 +843,7 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "2.0.3" +version = "2.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, @@ -851,9 +851,9 @@ dependencies = [ { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" }, ] [[package]] @@ -1009,11 +1009,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -1393,27 +1393,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" +version = "0.15.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] @@ -1427,75 +1427,70 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/76/f908955139842c362aa877848f42f9249642d5b69e06cee9eae5111da1bd/sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f", size = 2159321, upload-time = "2026-04-03T16:50:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/24/e2/17ba0b7bfbd8de67196889b6d951de269e8a46057d92baca162889beb16d/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b", size = 3238937, upload-time = "2026-04-03T16:54:45.731Z" }, - { url = "https://files.pythonhosted.org/packages/90/1e/410dd499c039deacff395eec01a9da057125fcd0c97e3badc252c6a2d6a7/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1", size = 3237188, upload-time = "2026-04-03T16:56:53.217Z" }, - { url = "https://files.pythonhosted.org/packages/ab/06/e797a8b98a3993ac4bc785309b9b6d005457fc70238ee6cefa7c8867a92e/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339", size = 3190061, upload-time = "2026-04-03T16:54:47.489Z" }, - { url = "https://files.pythonhosted.org/packages/44/d3/5a9f7ef580af1031184b38235da6ac58c3b571df01c9ec061c44b2b0c5a6/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d", size = 3211477, upload-time = "2026-04-03T16:56:55.056Z" }, - { url = "https://files.pythonhosted.org/packages/69/ec/7be8c8cb35f038e963a203e4fe5a028989167cc7299927b7cf297c271e37/sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3", size = 2119965, upload-time = "2026-04-03T17:00:50.009Z" }, - { url = "https://files.pythonhosted.org/packages/b5/31/0defb93e3a10b0cf7d1271aedd87251a08c3a597ee4f353281769b547b5a/sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75", size = 2142935, upload-time = "2026-04-03T17:00:51.675Z" }, - { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, - { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, - { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, - { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, - { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, - { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, - { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/70/a9/812a775bd8c1af0966d660238d005baf25e9bced1f038c8e71f00aa637a7/sqlalchemy-2.0.50-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2", size = 2161617, upload-time = "2026-05-24T20:00:00.761Z" }, + { url = "https://files.pythonhosted.org/packages/d5/74/5a6bc5496e9be8f740fbf80f9e6bd4ab965c8a80870eb07ab015e360957a/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f", size = 3244104, upload-time = "2026-05-24T20:07:38.158Z" }, + { url = "https://files.pythonhosted.org/packages/81/55/b260d8df2adc9bb0bf294f67b5f802ff0d84d99442b536b9efd0ea72d447/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21", size = 3243039, upload-time = "2026-05-24T20:14:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/58714005cbf370f16c3f30d30324a43be10069efcfe764f7236a2e851947/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39", size = 3195017, upload-time = "2026-05-24T20:07:40.086Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/67527fee039bd3e1a6ce3f03d2b62fd87ab9099c17052810d79496727b66/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b", size = 3215308, upload-time = "2026-05-24T20:14:26.034Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/dd3155a6a6706cb89adecf5ee6e0512f7b0ee5cf3e6f4cde67d3c20ebfda/sqlalchemy-2.0.50-cp310-cp310-win32.whl", hash = "sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031", size = 2121637, upload-time = "2026-05-24T20:08:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/a09c463ee3e7764b5ce5bd19a7f0b6eefbde62e637439ab58498cdbd6b47/sqlalchemy-2.0.50-cp310-cp310-win_amd64.whl", hash = "sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc", size = 2144673, upload-time = "2026-05-24T20:08:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5d/3172686af1770e4de2805f919a51441085f589ddadf3dd76ec582f84f497/sqlalchemy-2.0.50-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", size = 2161366, upload-time = "2026-05-24T20:00:02.061Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/e98dedea3c3e663a17afcd003a34ba45efdac2cea3b6f2e4585e2b1e2537/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", size = 3318926, upload-time = "2026-05-24T20:07:42.369Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/501308c2babb62c11753ecb4ee88ba9eef019419a4d6cbf7cb13e2bad353/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", size = 3319199, upload-time = "2026-05-24T20:14:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/d88996c5e03ed6248c3a788d20f0b8d8b376b9f8a495e4bab9df7c72d2f8/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", size = 3270301, upload-time = "2026-05-24T20:07:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/1ae0e65161b51cc43e5ca75430ef79d80e23b5042d645586c2c342c3b92e/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", size = 3293465, upload-time = "2026-05-24T20:14:30.501Z" }, + { url = "https://files.pythonhosted.org/packages/83/29/17c0003f2c0dfa6d1b97672475707e3ec5980db09defd7fa20beb6833bbd/sqlalchemy-2.0.50-cp311-cp311-win32.whl", hash = "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", size = 2120694, upload-time = "2026-05-24T20:08:09.237Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/280d00654cc19d1fccf236fa5070f6dd04b84dde6f1b2e637bde0ff340a7/sqlalchemy-2.0.50-cp311-cp311-win_amd64.whl", hash = "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", size = 2145315, upload-time = "2026-05-24T20:08:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] [[package]] name = "starlette" -version = "1.1.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] From 25977bce6a69df2bebc70c5439aa234ed642698d Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Fri, 12 Jun 2026 21:15:54 -0300 Subject: [PATCH 10/13] refactor: removes IR --- pyproject.toml | 12 +- src/pyrsql/__init__.py | 2 + src/pyrsql/adapters/fastapi/config.py | 54 ++--- src/pyrsql/core/binding_policy.py | 13 +- src/pyrsql/core/conversion.py | 16 +- src/pyrsql/core/json/values.py | 2 +- src/pyrsql/core/options.py | 21 +- src/pyrsql/core/page.py | 13 -- src/pyrsql/core/query.py | 7 +- src/pyrsql/core/sort.py | 9 +- .../fastapi/sqlalchemy/examples.py | 5 +- .../fastapi/sqlalchemy/helpers.py | 19 ++ .../fastapi/sqlalchemy/integration.py | 189 ++++++++--------- .../fastapi/sqlalchemy/resource.py | 200 ++++++++---------- src/pyrsql/ir/__init__.py | 24 --- src/pyrsql/ir/page.py | 28 --- src/pyrsql/ir/query.py | 128 ----------- src/pyrsql/ir/sort.py | 36 ---- src/pyrsql/orms/sqlalchemy/compiled_page.py | 10 +- src/pyrsql/orms/sqlalchemy/compiled_query.py | 6 +- src/pyrsql/orms/sqlalchemy/compiled_sort.py | 10 +- src/pyrsql/orms/sqlalchemy/introspection.py | 101 ++++----- src/pyrsql/orms/sqlalchemy/orm.py | 4 +- src/pyrsql/orms/sqlalchemy/resolver.py | 10 +- src/pyrsql/orms/sqlalchemy/sorter.py | 36 ++-- src/pyrsql/orms/sqlalchemy/statement.py | 2 +- src/pyrsql/orms/sqlalchemy/translator.py | 51 +++-- src/pyrsql/parsing/diagnostics.py | 2 +- src/pyrsql/selector/ast.py | 1 - src/pyrsql/selector/parser.py | 4 +- src/pyrsql/semantic/binder.py | 54 ++--- src/pyrsql/semantic/diagnostics.py | 2 +- src/pyrsql/sorting/binder.py | 47 ++-- .../test_async_integration.py | 2 +- .../unit/adapters/fastapi_adapter/conftest.py | 5 +- tests/unit/core/test_page.py | 2 - .../fastapi_sqlalchemy/test_integration.py | 65 ++---- tests/unit/ir/__init__.py | 1 - tests/unit/ir/test_page.py | 13 -- tests/unit/ir/test_query.py | 110 ---------- tests/unit/ir/test_sort.py | 63 ------ tests/unit/orms/sqlalchemy/test_resolver.py | 8 +- tests/unit/parsing/test_ast.py | 4 +- tests/unit/selector/test_ast.py | 1 - tests/unit/selector/test_parser.py | 1 - tests/unit/semantic/test_binder.py | 31 ++- tests/unit/sorting/test_ast.py | 2 +- tests/unit/sorting/test_binder.py | 17 +- 48 files changed, 472 insertions(+), 971 deletions(-) delete mode 100644 src/pyrsql/ir/__init__.py delete mode 100644 src/pyrsql/ir/page.py delete mode 100644 src/pyrsql/ir/query.py delete mode 100644 src/pyrsql/ir/sort.py delete mode 100644 tests/unit/ir/__init__.py delete mode 100644 tests/unit/ir/test_page.py delete mode 100644 tests/unit/ir/test_query.py delete mode 100644 tests/unit/ir/test_sort.py diff --git a/pyproject.toml b/pyproject.toml index d60dbba..290ca3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -191,21 +191,13 @@ markers = [ [tool.mypy] python_version = "3.10" -files = ["src", "tests"] +files = ["src"] +exclude = ["^tests/"] strict = true warn_unused_configs = true show_error_codes = true pretty = true -[[tool.mypy.overrides]] -module = [ - "integration.*", - "integration.sqlalchemy.*", -] -disallow_untyped_defs = false -disallow_incomplete_defs = false -disallow_untyped_calls = false - [tool.coverage.run] source = ["pyrsql"] branch = true diff --git a/src/pyrsql/__init__.py b/src/pyrsql/__init__.py index 17944ad..0954b39 100644 --- a/src/pyrsql/__init__.py +++ b/src/pyrsql/__init__.py @@ -1,5 +1,6 @@ """Public package interface for pyrsql.""" +from pyrsql import orms from pyrsql.api import apply, compile, parse # noqa: A004 from pyrsql.core import ( DEFAULT_JSON_OPTIONS, @@ -51,5 +52,6 @@ "ValueConverterRegistry", "apply", "compile", + "orms", "parse", ) diff --git a/src/pyrsql/adapters/fastapi/config.py b/src/pyrsql/adapters/fastapi/config.py index c41f995..e08cc8e 100644 --- a/src/pyrsql/adapters/fastapi/config.py +++ b/src/pyrsql/adapters/fastapi/config.py @@ -40,6 +40,24 @@ def _normalize_parameter_name(name: object, *, field_name: str) -> str: return normalized_name +def _normalize_openapi_examples_mapping( + value: object, + *, + field_name: str, +) -> Mapping[str, Any]: + """Validates and freezes one OpenAPI examples mapping. + + Returns: + An immutable copy of the provided examples mapping. + + Raises: + TypeError: If the provided value is not a mapping. + """ + if not isinstance(value, Mapping): + raise TypeError(f"{field_name} must be a mapping instance.") + return MappingProxyType(dict(value)) + + class FastAPICriteriaConfig( msgspec.Struct, frozen=True, @@ -123,36 +141,20 @@ def __post_init__(self) -> None: raise TypeError("query_options must be a QueryOptions instance.") if not isinstance(self.sort_options, SortOptions): raise TypeError("sort_options must be a SortOptions instance.") - if not isinstance(self.filter_openapi_examples, Mapping): - raise TypeError( - "filter_openapi_examples must be a mapping instance.", - ) - if not isinstance(self.sort_openapi_examples, Mapping): - raise TypeError("sort_openapi_examples must be a mapping instance.") - if not isinstance(self.page_openapi_examples, Mapping): - raise TypeError("page_openapi_examples must be a mapping instance.") - if not isinstance(self.size_openapi_examples, Mapping): - raise TypeError("size_openapi_examples must be a mapping instance.") - msgspec.structs.force_setattr( - self, + for field_name in ( "filter_openapi_examples", - MappingProxyType(dict(self.filter_openapi_examples)), - ) - msgspec.structs.force_setattr( - self, "sort_openapi_examples", - MappingProxyType(dict(self.sort_openapi_examples)), - ) - msgspec.structs.force_setattr( - self, "page_openapi_examples", - MappingProxyType(dict(self.page_openapi_examples)), - ) - msgspec.structs.force_setattr( - self, "size_openapi_examples", - MappingProxyType(dict(self.size_openapi_examples)), - ) + ): + msgspec.structs.force_setattr( + self, + field_name, + _normalize_openapi_examples_mapping( + getattr(self, field_name), + field_name=field_name, + ), + ) @property def minimum_page_number(self) -> int: diff --git a/src/pyrsql/core/binding_policy.py b/src/pyrsql/core/binding_policy.py index b688129..545c59e 100644 --- a/src/pyrsql/core/binding_policy.py +++ b/src/pyrsql/core/binding_policy.py @@ -2,11 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol +from collections.abc import Callable +from typing import TYPE_CHECKING, Protocol, TypeAlias if TYPE_CHECKING: from collections.abc import Mapping +ErrorFactory: TypeAlias = Callable[[str], Exception] + class ProcedurePolicyProtocol(Protocol): """Structural contract for procedure access policies.""" @@ -43,8 +46,8 @@ def enforce_field_access_policy( *, field_whitelist: frozenset[str] | set[str], field_blacklist: frozenset[str] | set[str], - not_whitelisted_error_factory: callable, - blacklisted_error_factory: callable, + not_whitelisted_error_factory: ErrorFactory, + blacklisted_error_factory: ErrorFactory, ) -> None: """Enforces whitelist/blacklist field access rules.""" if field_path in field_blacklist: @@ -61,8 +64,8 @@ def enforce_function_access_policy( function_name: str, *, procedure_policy: ProcedurePolicyProtocol, - not_whitelisted_error_factory: callable, - blacklisted_error_factory: callable, + not_whitelisted_error_factory: ErrorFactory, + blacklisted_error_factory: ErrorFactory, ) -> None: """Enforces whitelist/blacklist procedure access rules.""" if procedure_policy.is_blacklisted(function_name): diff --git a/src/pyrsql/core/conversion.py b/src/pyrsql/core/conversion.py index 473e47f..3776bbf 100644 --- a/src/pyrsql/core/conversion.py +++ b/src/pyrsql/core/conversion.py @@ -35,8 +35,9 @@ def __init__( cause: Exception | None = None, ) -> None: """Builds one normalized conversion error instance.""" + target_type_name = getattr(target_type, "__name__", repr(target_type)) super().__init__( - f"Failed to convert {raw_value!r} to {target_type.__name__}.", + f"Failed to convert {raw_value!r} to {target_type_name}.", ) if cause is not None: self.__cause__ = cause @@ -57,9 +58,7 @@ def _convert_bool(raw_value: RawValue) -> bool: case "false": return False case _: - raise ValueConversionError( - f"Cannot convert {raw_value!r} to bool.", - ) + raise ValueConversionError(raw_value, bool) def _convert_datetime(raw_value: RawValue) -> dt.datetime: @@ -237,7 +236,9 @@ def _convert_without_registered_converter( return self._convert_json_container(raw_value, target_type) except TypeError as error: # pragma: no cover - invalid type object raise ValueConversionError( - f"Unsupported target type {target_type!r}.", + raw_value, + target_type, + cause=error, ) from error return self._construct_from_string(raw_value, target_type) @@ -303,6 +304,7 @@ def _convert_json_container( container type fails. """ expected_type: TargetType + decoder: msgspec.json.Decoder[Any] if issubclass(target_type, dict): expected_type = dict decoder = _JSON_DICT_DECODER @@ -310,9 +312,7 @@ def _convert_json_container( expected_type = list decoder = _JSON_LIST_DECODER else: # pragma: no cover - guarded by convert() - raise ValueConversionError( - f"Unsupported target type {target_type!r}.", - ) + raise ValueConversionError(raw_value, target_type) try: decoded = decoder.decode(raw_value) diff --git a/src/pyrsql/core/json/values.py b/src/pyrsql/core/json/values.py index cd93bf0..f656863 100644 --- a/src/pyrsql/core/json/values.py +++ b/src/pyrsql/core/json/values.py @@ -108,7 +108,7 @@ def _try_parse_json_number(raw_value: str) -> int | float | None: return None if isinstance(parsed, bool) or not isinstance(parsed, int | float): return None - return cast("int | float", parsed) + return parsed DEFAULT_JSON_SCALAR_NORMALIZER = JSONScalarNormalizer() diff --git a/src/pyrsql/core/options.py b/src/pyrsql/core/options.py index ac4e130..d8fbdf1 100644 --- a/src/pyrsql/core/options.py +++ b/src/pyrsql/core/options.py @@ -3,7 +3,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar, cast import msgspec @@ -109,48 +109,49 @@ def _normalize_shared_policy_options( options: _SharedPolicyOptionsProtocol, ) -> None: """Normalizes option fields shared by query and sort configuration.""" + struct_options = cast("msgspec.Struct", options) msgspec.structs.force_setattr( - options, + struct_options, "field_mapping", _normalize_mapping(options.field_mapping), ) msgspec.structs.force_setattr( - options, + struct_options, "model_field_mapping", _normalize_nested_mapping(options.model_field_mapping), ) msgspec.structs.force_setattr( - options, + struct_options, "join_hints", _normalize_mapping(options.join_hints), ) msgspec.structs.force_setattr( - options, + struct_options, "field_whitelist", _normalize_frozenset(options.field_whitelist), ) msgspec.structs.force_setattr( - options, + struct_options, "field_blacklist", _normalize_frozenset(options.field_blacklist), ) msgspec.structs.force_setattr( - options, + struct_options, "model_field_whitelist", _normalize_nested_sets(options.model_field_whitelist), ) msgspec.structs.force_setattr( - options, + struct_options, "model_field_blacklist", _normalize_nested_sets(options.model_field_blacklist), ) msgspec.structs.force_setattr( - options, + struct_options, "procedure_whitelist", _normalize_tuple(options.procedure_whitelist), ) msgspec.structs.force_setattr( - options, + struct_options, "procedure_blacklist", _normalize_tuple(options.procedure_blacklist), ) diff --git a/src/pyrsql/core/page.py b/src/pyrsql/core/page.py index b3ffb0d..bcf25a6 100644 --- a/src/pyrsql/core/page.py +++ b/src/pyrsql/core/page.py @@ -7,7 +7,6 @@ import msgspec from pyrsql.core.compiler import PageCompilationResult -from pyrsql.ir.page import BoundPage if TYPE_CHECKING: from pyrsql.orms.base import ORM @@ -116,18 +115,6 @@ def limit(self) -> int: """ return self.page_size - @property - def bound_page(self) -> BoundPage: - """The logical pagination IR for this request. - - Returns: - The bound pagination IR for this request. - """ - return BoundPage( - page_number=self.page_number, - page_size=self.page_size, - ) - def compile(self, *, orm: ORM) -> PageCompilationResult: """Compiles the page request using the provided ORM. diff --git a/src/pyrsql/core/query.py b/src/pyrsql/core/query.py index 4e0ddfd..4d7bafd 100644 --- a/src/pyrsql/core/query.py +++ b/src/pyrsql/core/query.py @@ -12,7 +12,6 @@ from pyrsql.semantic.binder import SemanticBinder if TYPE_CHECKING: - from pyrsql.ir.query import BoundComparison, BoundLogical from pyrsql.orms.base import ORM from pyrsql.parsing.ast import Expression @@ -38,7 +37,7 @@ class Query(msgspec.Struct, frozen=True, gc=False): text: str options: QueryOptions expression: Expression - bound_expression: BoundComparison | BoundLogical + bound_expression: Expression @classmethod def parse( @@ -97,7 +96,7 @@ def bind_expression( expression: Expression, *, options: QueryOptions, - ) -> BoundComparison | BoundLogical: + ) -> Expression: """Binds a syntax tree into logical query IR. Args: @@ -105,7 +104,7 @@ def bind_expression( options: Query configuration used by semantic binding. Returns: - The bound logical query IR. + The semantically validated expression tree. """ return SemanticBinder(options).bind(expression) diff --git a/src/pyrsql/core/sort.py b/src/pyrsql/core/sort.py index 4a1cebe..8064a52 100644 --- a/src/pyrsql/core/sort.py +++ b/src/pyrsql/core/sort.py @@ -12,7 +12,6 @@ from pyrsql.sorting.parser import SortParser if TYPE_CHECKING: - from pyrsql.ir.sort import BoundSort from pyrsql.orms.base import ORM from pyrsql.sorting.ast import SortField @@ -35,7 +34,7 @@ class Sort(msgspec.Struct, frozen=True, gc=False): text: str | None options: SortOptions fields: tuple[SortField, ...] = () - bound_sort: BoundSort | None = None + bound_sort: tuple[SortField, ...] = () @classmethod def parse( @@ -88,7 +87,7 @@ def bind_fields( fields: tuple[SortField, ...], *, options: SortOptions, - ) -> BoundSort | None: + ) -> tuple[SortField, ...]: """Binds parsed sort fields into logical sort IR. Args: @@ -96,10 +95,10 @@ def bind_fields( options: Sort configuration used by semantic binding. Returns: - The bound logical sort IR, or None when no fields were parsed. + The semantically validated sort fields. """ if not fields: - return None + return () return SortBinder(options).bind(fields) def compile(self, *, orm: ORM) -> SortCompilationResult: diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py b/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py index 2c71cfe..7b57205 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/examples.py @@ -5,7 +5,7 @@ import datetime as dt from decimal import Decimal from enum import Enum -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast from uuid import UUID from pyrsql.core.field_policy import FieldPolicySet @@ -101,8 +101,9 @@ def _enum_filter_example_value(enum_type: type[Any]) -> str: Returns: A filter-compatible enum literal. """ + typed_enum = cast("type[Enum]", enum_type) try: - first_member = next(iter(enum_type)) + first_member = next(iter(typed_enum)) except StopIteration: return _DEFAULT_STRING_EXAMPLE enum_value = first_member.value diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py index 1054582..897f8c6 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/helpers.py @@ -10,6 +10,9 @@ from pyrsql.adapters.fastapi import RequestCriteria from pyrsql.adapters.fastapi.errors import FastAPIAdapterErrorPayload +from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, +) from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError if TYPE_CHECKING: @@ -141,3 +144,19 @@ def count_from_filtered_select( return select(func.count()).select_from( filtered_statement.order_by(None).subquery(), ) + + +def build_paginated_select( + *, + statement: SQLAlchemySelect, + filtered_statement: SQLAlchemySelect, +) -> SQLAlchemyPaginatedSelect: + """Builds the shared list/count payload for paginated query flows. + + Returns: + The paired list and count SQLAlchemy statements. + """ + return SQLAlchemyPaginatedSelect( + statement=statement, + count_statement=count_from_filtered_select(filtered_statement), + ) diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py index f42fc4c..1d29419 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/integration.py @@ -3,7 +3,7 @@ from __future__ import annotations from threading import Lock -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar from fastapi import Depends from sqlalchemy import select @@ -22,14 +22,12 @@ from pyrsql.integrations.fastapi.sqlalchemy.helpers import ( apply_query_with_orm, apply_sort_and_page_with_orm, + build_paginated_select, count_from_filtered_select, query_backend_http_errors, require_request_criteria, sort_backend_http_errors, ) -from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( - SQLAlchemyPaginatedSelect, -) from pyrsql.integrations.fastapi.sqlalchemy.resource import ( FastAPISQLAlchemyResource, ) @@ -42,9 +40,13 @@ from pyrsql.adapters.fastapi import ( RequestCriteria, ) + from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, + ) from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect _DEFAULT_FASTAPI_CRITERIA_CONFIG = FastAPICriteriaConfig() +_CachedValueT = TypeVar("_CachedValueT") class FastAPISQLAlchemyIntegration: @@ -67,21 +69,7 @@ def __init__( orm: SQLAlchemyORM | None = None, criteria_config: FastAPICriteriaConfig | None = None, ) -> None: - """Creates an integration helper for FastAPI and SQLAlchemy. - - Raises: - TypeError: If ``orm`` or ``criteria_config`` has the wrong runtime - type. - """ - if orm is not None and not isinstance(orm, SQLAlchemyORM): - raise TypeError("orm must be a SQLAlchemyORM or None.") - if criteria_config is not None and not isinstance( - criteria_config, - FastAPICriteriaConfig, - ): - raise TypeError( - "criteria_config must be a FastAPICriteriaConfig or None.", - ) + """Creates an integration helper for FastAPI and SQLAlchemy.""" self.orm = SQLAlchemyORM() if orm is None else orm self.criteria_config = ( _DEFAULT_FASTAPI_CRITERIA_CONFIG @@ -169,13 +157,11 @@ def base_select(self, model: SQLAlchemyModel) -> SQLAlchemySelect: statement = self._base_selects.get(model) if statement is not None: return statement - with self._cache_lock: - statement = self._base_selects.get(model) - if statement is not None: - return statement - statement = select(model) - self._base_selects[model] = statement - return statement + return self._publish_cached_model_value( + self._base_selects, + model, + select(model), + ) def apply( self, @@ -237,13 +223,13 @@ def paginated_select( """ criteria = require_request_criteria(criteria) filtered_statement = self._filtered_select(model, criteria) - return SQLAlchemyPaginatedSelect( + return build_paginated_select( statement=self._apply_sort_and_page( filtered_statement, model, criteria, ), - count_statement=count_from_filtered_select(filtered_statement), + filtered_statement=filtered_statement, ) def select_dependency( @@ -258,29 +244,28 @@ def select_dependency( cached_dependency = self._select_dependencies.get(model) if cached_dependency is not None: return cached_dependency - with self._cache_lock: - cached_dependency = self._select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_select( - model, - criteria, - ) - with sort_backend_http_errors(self.criteria_config): - return self._apply_sort_and_page( - filtered_statement, - model, - criteria, - ) - - self._select_dependencies[model] = dependency - return dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return self._apply_sort_and_page( + filtered_statement, + model, + criteria, + ) + + return self._publish_cached_model_value( + self._select_dependencies, + model, + dependency, + ) def count_select_dependency( self, @@ -294,24 +279,23 @@ def count_select_dependency( cached_dependency = self._count_select_dependencies.get(model) if cached_dependency is not None: return cached_dependency - with self._cache_lock: - cached_dependency = self._count_select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_select( - model, - criteria, - ) - return count_from_filtered_select(filtered_statement) - - self._count_select_dependencies[model] = dependency - return dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + criteria, + ) + return count_from_filtered_select(filtered_statement) + + return self._publish_cached_model_value( + self._count_select_dependencies, + model, + dependency, + ) def paginated_select_dependency( self, @@ -325,34 +309,49 @@ def paginated_select_dependency( cached_dependency = self._paginated_select_dependencies.get(model) if cached_dependency is not None: return cached_dependency - with self._cache_lock: - cached_dependency = self._paginated_select_dependencies.get(model) - if cached_dependency is not None: - return cached_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemyPaginatedSelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_select( + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemyPaginatedSelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_select( + model, + criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return build_paginated_select( + statement=self._apply_sort_and_page( + filtered_statement, model, criteria, - ) - with sort_backend_http_errors(self.criteria_config): - return SQLAlchemyPaginatedSelect( - statement=self._apply_sort_and_page( - filtered_statement, - model, - criteria, - ), - count_statement=count_from_filtered_select( - filtered_statement, - ), - ) - - self._paginated_select_dependencies[model] = dependency - return dependency + ), + filtered_statement=filtered_statement, + ) + + return self._publish_cached_model_value( + self._paginated_select_dependencies, + model, + dependency, + ) + + def _publish_cached_model_value( + self, + cache: dict[SQLAlchemyModel, _CachedValueT], + model: SQLAlchemyModel, + value: _CachedValueT, + ) -> _CachedValueT: + """Publishes one cached model-scoped value with minimal lock scope. + + Returns: + The cached value shared for the model. + """ + with self._cache_lock: + cached_value = cache.get(model) + if cached_value is not None: + return cached_value + cache[model] = value + return value def resource( self, diff --git a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py index 38a1bf0..afeb1f5 100644 --- a/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py +++ b/src/pyrsql/integrations/fastapi/sqlalchemy/resource.py @@ -3,7 +3,7 @@ from __future__ import annotations from threading import Lock -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar, cast from fastapi import Depends @@ -12,29 +12,31 @@ FastAPICriteriaConfig, RequestCriteria, ) -from pyrsql.core.sort import Sort as PyrsqlSort from pyrsql.integrations.fastapi.sqlalchemy.helpers import ( apply_query_with_orm, apply_sort_and_page_with_orm, + build_paginated_select, count_from_filtered_select, query_backend_http_errors, require_request_criteria, sort_backend_http_errors, ) -import pyrsql.integrations.fastapi.sqlalchemy.integration -from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( - SQLAlchemyPaginatedSelect, -) from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select if TYPE_CHECKING: from collections.abc import Callable + from pyrsql.core.sort import Sort as PyrsqlSort from pyrsql.integrations.fastapi.sqlalchemy.integration import ( FastAPISQLAlchemyIntegration, ) + from pyrsql.integrations.fastapi.sqlalchemy.payloads import ( + SQLAlchemyPaginatedSelect, + ) from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect +_CachedValueT = TypeVar("_CachedValueT") + class FastAPISQLAlchemyResource: """Declarative FastAPI + SQLAlchemy resource integration.""" @@ -75,31 +77,7 @@ def __init__( default_sort: PyrsqlSort | None = None, statement_factory: Callable[[], SQLAlchemySelect] | None = None, ) -> None: - """Creates a route-ready resource for one SQLAlchemy model. - - Raises: - TypeError: If the integration, criteria config, default sort, or - statement factory have the wrong runtime type. - """ - integration_type = ( - pyrsql.integrations.fastapi.sqlalchemy.integration.FastAPISQLAlchemyIntegration - ) - if not isinstance( - integration, - integration_type, - ): - raise TypeError( - "integration must be a FastAPISQLAlchemyIntegration.", - ) - if not isinstance(criteria_config, FastAPICriteriaConfig): - raise TypeError("criteria_config must be a FastAPICriteriaConfig.") - if default_sort is not None and not isinstance( - default_sort, - PyrsqlSort, - ): - raise TypeError("default_sort must be a Sort or None.") - if statement_factory is not None and not callable(statement_factory): - raise TypeError("statement_factory must be a callable or None.") + """Creates a route-ready resource for one SQLAlchemy model.""" self.integration = integration self.model = model self.criteria_config = criteria_config @@ -148,23 +126,10 @@ def _base_statement(self) -> SQLAlchemySelect: Returns: The base SQLAlchemy select statement for the resource. - - Raises: - TypeError: If the statement factory returns an incompatible - statement. """ if self._statement_factory is None: return self.integration.base_select(self.model) - statement = require_sqlalchemy_select(self._statement_factory()) - if not any( - description.get("entity") is self.model - for description in statement.column_descriptions - ): - raise TypeError( - "statement_factory must return a Select compatible " - f"with model {self.model.__name__}.", - ) - return statement + return require_sqlalchemy_select(self._statement_factory()) def select(self, criteria: RequestCriteria) -> SQLAlchemySelect: """Builds a filtered select for the configured resource model. @@ -241,12 +206,12 @@ def _paginated_bundle( Returns: The paired list and count statements. """ - return SQLAlchemyPaginatedSelect( + return build_paginated_select( statement=self._sorted_statement( filtered_statement, criteria, ), - count_statement=count_from_filtered_select(filtered_statement), + filtered_statement=filtered_statement, ) def count_select(self, criteria: RequestCriteria) -> SQLAlchemySelect: @@ -280,26 +245,22 @@ def select_dependency(self) -> Callable[..., SQLAlchemySelect]: """ if self._select_dependency is not None: return self._select_dependency - with self._cache_lock: - if self._select_dependency is not None: - return self._select_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_statement( - criteria, - ) - with sort_backend_http_errors(self.criteria_config): - return self._sorted_statement( - filtered_statement, - criteria, - ) - - self._select_dependency = dependency - return dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_statement( + criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return self._sorted_statement( + filtered_statement, + criteria, + ) + + return self._publish_cached_dependency("_select_dependency", dependency) def applier_dependency( self, @@ -311,18 +272,17 @@ def applier_dependency( """ if self._applier_dependency is not None: return self._applier_dependency - with self._cache_lock: - if self._applier_dependency is not None: - return self._applier_dependency - criteria_dependency = self.criteria_dependency() + criteria_dependency = self.criteria_dependency() - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> Callable[[SQLAlchemySelect], SQLAlchemySelect]: - return self.applier(criteria) + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> Callable[[SQLAlchemySelect], SQLAlchemySelect]: + return self.applier(criteria) - self._applier_dependency = dependency - return dependency + return self._publish_cached_dependency( + "_applier_dependency", + dependency, + ) def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: """Returns a FastAPI dependency yielding a count select. @@ -332,22 +292,21 @@ def count_select_dependency(self) -> Callable[..., SQLAlchemySelect]: """ if self._count_select_dependency is not None: return self._count_select_dependency - with self._cache_lock: - if self._count_select_dependency is not None: - return self._count_select_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemySelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_statement( - criteria, - ) - return count_from_filtered_select(filtered_statement) - - self._count_select_dependency = dependency - return dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemySelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_statement( + criteria, + ) + return count_from_filtered_select(filtered_statement) + + return self._publish_cached_dependency( + "_count_select_dependency", + dependency, + ) def paginated_select_dependency( self, @@ -359,23 +318,42 @@ def paginated_select_dependency( """ if self._paginated_select_dependency is not None: return self._paginated_select_dependency + criteria_dependency = self.criteria_dependency() + + def dependency( + criteria: RequestCriteria = Depends(criteria_dependency), + ) -> SQLAlchemyPaginatedSelect: + with query_backend_http_errors(self.criteria_config): + filtered_statement = self._filtered_statement( + criteria, + ) + with sort_backend_http_errors(self.criteria_config): + return self._paginated_bundle( + filtered_statement, + criteria, + ) + + return self._publish_cached_dependency( + "_paginated_select_dependency", + dependency, + ) + + def _publish_cached_dependency( + self, + attribute_name: str, + dependency: _CachedValueT, + ) -> _CachedValueT: + """Publishes one cached dependency with minimal lock scope. + + Returns: + The shared cached dependency. + """ with self._cache_lock: - if self._paginated_select_dependency is not None: - return self._paginated_select_dependency - criteria_dependency = self.criteria_dependency() - - def dependency( - criteria: RequestCriteria = Depends(criteria_dependency), - ) -> SQLAlchemyPaginatedSelect: - with query_backend_http_errors(self.criteria_config): - filtered_statement = self._filtered_statement( - criteria, - ) - with sort_backend_http_errors(self.criteria_config): - return self._paginated_bundle( - filtered_statement, - criteria, - ) - - self._paginated_select_dependency = dependency + cached_dependency = cast( + "_CachedValueT | None", + getattr(self, attribute_name), + ) + if cached_dependency is not None: + return cached_dependency + setattr(self, attribute_name, dependency) return dependency diff --git a/src/pyrsql/ir/__init__.py b/src/pyrsql/ir/__init__.py deleted file mode 100644 index ffd2dee..0000000 --- a/src/pyrsql/ir/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Logical intermediate representation for pyrsql.""" - -from pyrsql.ir.page import BoundPage -from pyrsql.ir.query import ( - BoundArgument, - BoundComparison, - BoundField, - BoundFunction, - BoundLiteral, - BoundLogical, -) -from pyrsql.ir.sort import BoundSort, BoundSortField - -__all__ = [ - "BoundArgument", - "BoundComparison", - "BoundField", - "BoundFunction", - "BoundLiteral", - "BoundLogical", - "BoundPage", - "BoundSort", - "BoundSortField", -] diff --git a/src/pyrsql/ir/page.py b/src/pyrsql/ir/page.py deleted file mode 100644 index e79290c..0000000 --- a/src/pyrsql/ir/page.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Bound logical pagination nodes.""" - -import msgspec - - -class BoundPage(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """A backend-independent pagination request.""" - - page_number: int - page_size: int - - @property - def offset(self) -> int: - """Returns the zero-based row offset. - - Returns: - The zero-based row offset. - """ - return self.page_number * self.page_size - - @property - def limit(self) -> int: - """Returns the maximum number of rows. - - Returns: - The maximum number of rows. - """ - return self.page_size diff --git a/src/pyrsql/ir/query.py b/src/pyrsql/ir/query.py deleted file mode 100644 index df2db58..0000000 --- a/src/pyrsql/ir/query.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Bound logical query nodes.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Final - -import msgspec - -if TYPE_CHECKING: - from collections.abc import Iterator - - from pyrsql.parsing.ast import LogicalOperator - from pyrsql.parsing.operators import ComparisonOperator - from pyrsql.parsing.source import SourceSpan - from pyrsql.selector.ast import SelectorLiteral - -_MIN_LOGICAL_CHILDREN: Final = 2 - - -class BoundNode(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """Base class for bound logical nodes.""" - - span: SourceSpan - - def walk(self) -> Iterator[BoundNode]: - """Yields this node and all nested bound nodes depth-first.""" - yield self - - -class BoundSelectorNode(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """Base class for bound selector nodes.""" - - def walk_selectors(self) -> Iterator[BoundSelectorNode]: - """Yields this selector and all nested selector nodes depth-first.""" - yield self - - -class BoundField(BoundSelectorNode, frozen=True, gc=False, kw_only=True): - """A bound field selector after policy and mapping resolution.""" - - raw_path: str - field_path: str - segments: tuple[str, ...] - - -class BoundLiteral(BoundSelectorNode, frozen=True, gc=False, kw_only=True): - """A bound literal selector.""" - - value: SelectorLiteral - - -class BoundFunction(BoundSelectorNode, frozen=True, gc=False, kw_only=True): - """A bound function selector.""" - - function_name: str - arguments: tuple[BoundSelectorNode, ...] - - def walk_selectors(self) -> Iterator[BoundSelectorNode]: - """Yields this selector and all nested selector nodes.""" - yield self - for argument in self.arguments: - yield from argument.walk_selectors() - - -class BoundArgument(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """A bound comparison argument. - - The argument remains intentionally raw at this stage. Type coercion still - depends on later target-aware metadata. - """ - - text: str - quoted: bool - span: SourceSpan - - -class BoundComparison(BoundNode, frozen=True, gc=False, kw_only=True): - """A bound comparison node.""" - - selector: BoundSelectorNode - operator: ComparisonOperator - arguments: tuple[BoundArgument, ...] - - def __post_init__(self) -> None: - """Validates bound comparison invariants. - - Raises: - ValueError: If the arguments do not satisfy operator arity. - """ - if len(self.arguments) < self.operator.minimum_arguments: - raise ValueError( - "Bound comparison is missing required operator arguments.", - ) - if ( - self.operator.maximum_arguments is not None - and len(self.arguments) > self.operator.maximum_arguments - ): - raise ValueError( - "Bound comparison exceeds the operator argument limit.", - ) - - def walk(self) -> Iterator[BoundNode]: - """Yields this comparison node.""" - yield self - - -class BoundLogical(BoundNode, frozen=True, gc=False, kw_only=True): - """A bound logical node.""" - - operator: LogicalOperator - children: tuple[BoundNode, ...] - - def __post_init__(self) -> None: - """Validates bound logical invariants. - - Raises: - ValueError: If the logical node has fewer than two children. - """ - if len(self.children) < _MIN_LOGICAL_CHILDREN: - raise ValueError( - "Bound logical nodes must contain at least two children.", - ) - - def walk(self) -> Iterator[BoundNode]: - """Yields this node and all descendant nodes depth-first.""" - yield self - for child in self.children: - yield from child.walk() diff --git a/src/pyrsql/ir/sort.py b/src/pyrsql/ir/sort.py deleted file mode 100644 index 24889fc..0000000 --- a/src/pyrsql/ir/sort.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Bound logical sort nodes.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import msgspec - -if TYPE_CHECKING: - from pyrsql.ir.query import BoundSelectorNode - from pyrsql.sorting.ast import SortDirection - - -class BoundSortField(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """A single bound sort field.""" - - selector: BoundSelectorNode - direction: SortDirection - ignore_case: bool - - -class BoundSort(msgspec.Struct, frozen=True, gc=False, kw_only=True): - """A bound sort request.""" - - fields: tuple[BoundSortField, ...] - - def __post_init__(self) -> None: - """Validates bound sort invariants. - - Raises: - ValueError: If no bound sort fields are present. - """ - if not self.fields: - raise ValueError( - "Bound sort must contain at least one bound sort field.", - ) diff --git a/src/pyrsql/orms/sqlalchemy/compiled_page.py b/src/pyrsql/orms/sqlalchemy/compiled_page.py index 2e23530..a4d4a79 100644 --- a/src/pyrsql/orms/sqlalchemy/compiled_page.py +++ b/src/pyrsql/orms/sqlalchemy/compiled_page.py @@ -9,7 +9,7 @@ from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select if TYPE_CHECKING: - from pyrsql.ir.page import BoundPage + from pyrsql.core.page import PageRequest from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect @@ -22,10 +22,10 @@ class SQLAlchemyCompiledPageRequest( """Compiled SQLAlchemy pagination plan. Attributes: - page: Bound pagination request to apply to a select statement. + page_request: Pagination request to apply to a select statement. """ - page: BoundPage + page_request: PageRequest def apply( self, @@ -45,4 +45,6 @@ def apply( """ del model statement = require_sqlalchemy_select(target) - return statement.limit(self.page.limit).offset(self.page.offset) + return statement.limit(self.page_request.limit).offset( + self.page_request.offset, + ) diff --git a/src/pyrsql/orms/sqlalchemy/compiled_query.py b/src/pyrsql/orms/sqlalchemy/compiled_query.py index d271fd7..c77dcea 100644 --- a/src/pyrsql/orms/sqlalchemy/compiled_query.py +++ b/src/pyrsql/orms/sqlalchemy/compiled_query.py @@ -13,9 +13,9 @@ if TYPE_CHECKING: from pyrsql.core.options import QueryOptions - from pyrsql.ir.query import BoundComparison, BoundLogical from pyrsql.orms.sqlalchemy.translator import SQLAlchemyExpressionTranslator from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect + from pyrsql.parsing.ast import Expression class SQLAlchemyCompiledQuery( @@ -27,12 +27,12 @@ class SQLAlchemyCompiledQuery( """Compiled SQLAlchemy query plan. Attributes: - expression: Bound query IR to lower into SQLAlchemy clauses. + expression: Semantically validated query expression to lower. options: Query configuration used during translation. translator: Translator responsible for producing SQLAlchemy objects. """ - expression: BoundComparison | BoundLogical + expression: Expression options: QueryOptions translator: SQLAlchemyExpressionTranslator diff --git a/src/pyrsql/orms/sqlalchemy/compiled_sort.py b/src/pyrsql/orms/sqlalchemy/compiled_sort.py index 36abce5..fb7f1ff 100644 --- a/src/pyrsql/orms/sqlalchemy/compiled_sort.py +++ b/src/pyrsql/orms/sqlalchemy/compiled_sort.py @@ -13,9 +13,9 @@ if TYPE_CHECKING: from pyrsql.core.options import SortOptions - from pyrsql.ir.sort import BoundSort from pyrsql.orms.sqlalchemy.sorter import SQLAlchemySortTranslator from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect + from pyrsql.sorting.ast import SortField class SQLAlchemyCompiledSort( @@ -27,12 +27,12 @@ class SQLAlchemyCompiledSort( """Compiled SQLAlchemy sort plan. Attributes: - sort_plan: Bound sort IR to lower into order clauses. + sort_fields: Semantically validated sort fields to lower. options: Sort configuration used during translation. translator: Translator responsible for producing SQLAlchemy objects. """ - sort_plan: BoundSort | None + sort_fields: tuple[SortField, ...] options: SortOptions translator: SQLAlchemySortTranslator @@ -51,11 +51,11 @@ def apply( A SQLAlchemy select with joins and ordering applied. """ statement = require_sqlalchemy_select(target) - if self.sort_plan is None: + if not self.sort_fields: return statement joins, order_clauses = self.translator.translate( model, - self.sort_plan, + self.sort_fields, options=self.options, ) statement = apply_relationship_joins( diff --git a/src/pyrsql/orms/sqlalchemy/introspection.py b/src/pyrsql/orms/sqlalchemy/introspection.py index e49d295..ce9ef7a 100644 --- a/src/pyrsql/orms/sqlalchemy/introspection.py +++ b/src/pyrsql/orms/sqlalchemy/introspection.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from sqlalchemy.orm.properties import ColumnProperty - from sqlalchemy.sql.schema import Column + from sqlalchemy.sql.elements import NamedColumn class SQLAlchemyModelInspector: @@ -50,11 +50,13 @@ def inspect_model(self, model: type[Any]) -> Mapper[Any]: cached_mapper = self._mapper_cache.get(model) if cached_mapper is not None: return cached_mapper + mapper = self._inspect_model(model) with self._mapper_cache_lock: cached_mapper = self._mapper_cache.get(model) if cached_mapper is not None: return cached_mapper - return self._inspect_model_locked(model) + self._mapper_cache[model] = mapper + return mapper def get_mapped_attribute( self, @@ -65,58 +67,23 @@ def get_mapped_attribute( Returns: Metadata for the mapped attribute. - - Raises: - SQLAlchemyModelInspectionError: If the attribute is not mapped. """ cache_key = (model, attribute_name) cached_attribute = self._attribute_cache.get(cache_key) if cached_attribute is not None: return cached_attribute + mapped_attribute = self._inspect_mapped_attribute(model, attribute_name) with self._attribute_cache_lock: cached_attribute = self._attribute_cache.get(cache_key) if cached_attribute is not None: return cached_attribute + self._attribute_cache[cache_key] = mapped_attribute + return mapped_attribute - mapper = self.inspect_model(model) - if attribute_name in mapper.relationships: - relationship = mapper.relationships[attribute_name] - return self._cache_mapped_attribute( - cache_key, - SQLAlchemyMappedAttribute( - name=attribute_name, - kind=SQLAlchemyAttributeKind.RELATIONSHIP, - owner_model=model, - attribute=getattr(model, attribute_name), - mapper=relationship.mapper, - python_type=relationship.mapper.class_, - is_collection=bool(relationship.uselist), - ), - ) - if attribute_name in mapper.column_attrs: - column_property = mapper.column_attrs[attribute_name] - return self._cache_mapped_attribute( - cache_key, - SQLAlchemyMappedAttribute( - name=attribute_name, - kind=SQLAlchemyAttributeKind.COLUMN, - owner_model=model, - attribute=getattr(model, attribute_name), - mapper=None, - python_type=self._resolve_column_python_type( - column_property, - ), - is_json=self._is_json_column(column_property), - ), - ) - raise SQLAlchemyModelInspectionError( - f"Attribute {attribute_name!r} is not mapped on model " - f"{model.__name__!r}.", - ) - - def _inspect_model_locked(self, model: type[Any]) -> Mapper[Any]: - """Resolves and caches one mapper while the cache lock is held. + @staticmethod + def _inspect_model(model: type[Any]) -> Mapper[Any]: + """Resolves one mapper through SQLAlchemy inspection APIs. Returns: The SQLAlchemy mapper for the model. @@ -124,9 +91,6 @@ def _inspect_model_locked(self, model: type[Any]) -> Mapper[Any]: Raises: SQLAlchemyModelInspectionError: If the model is not mapped. """ - cached_mapper = self._mapper_cache.get(model) - if cached_mapper is not None: - return cached_mapper try: mapper = inspect(model) except NoInspectionAvailable as error: @@ -137,26 +101,53 @@ def _inspect_model_locked(self, model: type[Any]) -> Mapper[Any]: raise SQLAlchemyModelInspectionError( f"Type {model!r} did not resolve to a SQLAlchemy ORM mapper.", ) - self._mapper_cache[model] = mapper return mapper - def _cache_mapped_attribute( + def _inspect_mapped_attribute( self, - cache_key: tuple[type[Any], str], - mapped_attribute: SQLAlchemyMappedAttribute, + model: type[Any], + attribute_name: str, ) -> SQLAlchemyMappedAttribute: - """Stores one resolved attribute in the local cache. + """Builds metadata for one mapped attribute. Returns: - The cached mapped attribute. + Metadata for the mapped attribute. + + Raises: + SQLAlchemyModelInspectionError: If the attribute is not mapped. """ - self._attribute_cache[cache_key] = mapped_attribute - return mapped_attribute + mapper = self.inspect_model(model) + if attribute_name in mapper.relationships: + relationship = mapper.relationships[attribute_name] + return SQLAlchemyMappedAttribute( + name=attribute_name, + kind=SQLAlchemyAttributeKind.RELATIONSHIP, + owner_model=model, + attribute=getattr(model, attribute_name), + mapper=relationship.mapper, + python_type=relationship.mapper.class_, + is_collection=bool(relationship.uselist), + ) + if attribute_name in mapper.column_attrs: + column_property = mapper.column_attrs[attribute_name] + return SQLAlchemyMappedAttribute( + name=attribute_name, + kind=SQLAlchemyAttributeKind.COLUMN, + owner_model=model, + attribute=getattr(model, attribute_name), + mapper=None, + python_type=self._resolve_column_python_type(column_property), + is_json=self._is_json_column(column_property), + ) + raise SQLAlchemyModelInspectionError( + f"Attribute {attribute_name!r} is not mapped on model " + f"{model.__name__!r}.", + ) @staticmethod def _first_column( column_property: ColumnProperty[Any], - ) -> Column[Any] | None: + ) -> NamedColumn[Any] | None: """Returns the first mapped column for a column property, if any.""" if not column_property.columns: return None diff --git a/src/pyrsql/orms/sqlalchemy/orm.py b/src/pyrsql/orms/sqlalchemy/orm.py index cbbc356..08d552e 100644 --- a/src/pyrsql/orms/sqlalchemy/orm.py +++ b/src/pyrsql/orms/sqlalchemy/orm.py @@ -101,7 +101,7 @@ def compile_sort(self, sort: Sort) -> SQLAlchemyCompiledSort: # type: ignore[ov A SQLAlchemy-specific compiled sort object. """ return SQLAlchemyCompiledSort( - sort_plan=sort.bound_sort, + sort_fields=sort.bound_sort, options=sort.options, translator=self._sort_translator, ) @@ -116,4 +116,4 @@ def compile_page_request( # type: ignore[override] Returns: A SQLAlchemy-specific compiled page request object. """ - return SQLAlchemyCompiledPageRequest(page=page_request.bound_page) + return SQLAlchemyCompiledPageRequest(page_request=page_request) diff --git a/src/pyrsql/orms/sqlalchemy/resolver.py b/src/pyrsql/orms/sqlalchemy/resolver.py index 431e755..9778b54 100644 --- a/src/pyrsql/orms/sqlalchemy/resolver.py +++ b/src/pyrsql/orms/sqlalchemy/resolver.py @@ -67,15 +67,15 @@ def resolve( cached_path = self._default_resolution_cache.get(cache_key) if cached_path is not None: return cached_path + resolved_path = self._resolve_with_field_policy( + model, + field_path, + field_policy=None, + ) with self._cache_lock: cached_path = self._default_resolution_cache.get(cache_key) if cached_path is not None: return cached_path - resolved_path = self._resolve_with_field_policy( - model, - field_path, - field_policy=None, - ) self._default_resolution_cache[cache_key] = resolved_path return resolved_path return self._resolve_with_field_policy( diff --git a/src/pyrsql/orms/sqlalchemy/sorter.py b/src/pyrsql/orms/sqlalchemy/sorter.py index 1dc68cf..ba2be57 100644 --- a/src/pyrsql/orms/sqlalchemy/sorter.py +++ b/src/pyrsql/orms/sqlalchemy/sorter.py @@ -8,11 +8,6 @@ import sqlalchemy as sa from pyrsql.core.json.options import DEFAULT_JSON_OPTIONS, JSONSortScalarType -from pyrsql.ir.query import ( - BoundField, - BoundFunction, - BoundLiteral, -) from pyrsql.orms.sqlalchemy.json_path import ( SQLAlchemyJSONPathExpressionBuilder, ) @@ -21,20 +16,23 @@ infer_sql_function_python_type, is_string_python_type, ) +from pyrsql.selector.ast import ( + FieldSelector, + FunctionSelector, + LiteralSelector, +) from pyrsql.sorting.ast import SortDirection if TYPE_CHECKING: from sqlalchemy.sql.elements import ColumnElement from pyrsql.core.options import SortOptions - from pyrsql.ir.query import ( - BoundSelectorNode, - ) - from pyrsql.ir.sort import BoundSort, BoundSortField from pyrsql.orms.sqlalchemy.types import ( SQLAlchemyJoinPlan, SQLAlchemyResolvedPath, ) + from pyrsql.selector.ast import SelectorNode + from pyrsql.sorting.ast import SortField _JSON_SORT_PYTHON_TYPES = MappingProxyType( @@ -49,7 +47,7 @@ class SQLAlchemySortTranslator: - """Lowers bound sort IR to SQLAlchemy order clauses.""" + """Lowers semantically validated sort fields to SQLAlchemy order clauses.""" __slots__ = ("_json_path_builder", "_path_resolver") @@ -74,7 +72,7 @@ def __init__( def translate( self, model: type[Any], - sort_plan: BoundSort, + sort_fields: tuple[SortField, ...], *, options: SortOptions | None = None, ) -> tuple[ @@ -88,7 +86,7 @@ def translate( """ joins: list[SQLAlchemyJoinPlan] = [] order_clauses: list[ColumnElement[Any]] = [] - for field in sort_plan.fields: + for field in sort_fields: selector_joins, expression, python_type = self._translate_selector( model, field.selector, @@ -107,7 +105,7 @@ def translate( def _translate_selector( self, model: type[Any], - selector: BoundSelectorNode, + selector: SelectorNode, *, options: SortOptions | None = None, ) -> tuple[ @@ -123,10 +121,10 @@ def _translate_selector( Raises: TypeError: If the selector is not a supported selector node. """ - if isinstance(selector, BoundField): + if isinstance(selector, FieldSelector): resolved_path = self._path_resolver.resolve( model, - selector.field_path, + selector.raw_path, field_policy=options.field_policy if options else None, ) return ( @@ -144,14 +142,14 @@ def _translate_selector( else resolved_path.python_type ), ) - if isinstance(selector, BoundLiteral): + if isinstance(selector, LiteralSelector): python_type = ( type(selector.value) if selector.value is not None else None ) return (), sa.literal(selector.value), python_type - if not isinstance(selector, BoundFunction): - raise TypeError("Expected BoundFunction") + if not isinstance(selector, FunctionSelector): + raise TypeError("Expected FunctionSelector") joins: list[SQLAlchemyJoinPlan] = [] argument_expressions: list[ColumnElement[Any]] = [] argument_types: list[type[Any] | None] = [] @@ -228,7 +226,7 @@ def _resolve_json_python_type( def _build_order_clause( expression: ColumnElement[Any], python_type: type[Any] | None, - field: BoundSortField, + field: SortField, ) -> ColumnElement[Any]: """Builds an ORDER BY clause for a resolved sort field. diff --git a/src/pyrsql/orms/sqlalchemy/statement.py b/src/pyrsql/orms/sqlalchemy/statement.py index d76ac46..d8397f2 100644 --- a/src/pyrsql/orms/sqlalchemy/statement.py +++ b/src/pyrsql/orms/sqlalchemy/statement.py @@ -18,7 +18,7 @@ SQLAlchemySelect, ) -_EMPTY_JOIN_HINTS = MappingProxyType({}) +_EMPTY_JOIN_HINTS: Mapping[str, JoinHint] = MappingProxyType({}) def require_sqlalchemy_select(target: object) -> SQLAlchemySelect: diff --git a/src/pyrsql/orms/sqlalchemy/translator.py b/src/pyrsql/orms/sqlalchemy/translator.py index 9a81279..4ae404d 100644 --- a/src/pyrsql/orms/sqlalchemy/translator.py +++ b/src/pyrsql/orms/sqlalchemy/translator.py @@ -8,12 +8,6 @@ import sqlalchemy as sa from pyrsql.core.json.query import JSONPathComparison -from pyrsql.ir.query import ( - BoundComparison, - BoundField, - BoundFunction, - BoundLiteral, -) from pyrsql.orms.sqlalchemy.coercion import SQLAlchemyValueCoercer from pyrsql.orms.sqlalchemy.custom import ( SQLAlchemyCustomPredicateInput, @@ -27,7 +21,7 @@ infer_sql_function_python_type, is_string_python_type, ) -from pyrsql.parsing.ast import LogicalOperator +from pyrsql.parsing.ast import ComparisonNode, LogicalNode, LogicalOperator from pyrsql.parsing.operators import ( BETWEEN, EQUAL, @@ -47,6 +41,11 @@ NOT_LIKE, NOT_NULL, ) +from pyrsql.selector.ast import ( + FieldSelector, + FunctionSelector, + LiteralSelector, +) if TYPE_CHECKING: from collections.abc import Mapping @@ -54,10 +53,6 @@ from sqlalchemy.sql.elements import ColumnElement from pyrsql.core.options import QueryOptions - from pyrsql.ir.query import ( - BoundLogical, - BoundSelectorNode, - ) from pyrsql.orms.sqlalchemy.custom import ( SQLAlchemyCustomPredicate, ) @@ -65,6 +60,8 @@ SQLAlchemyJoinPlan, SQLAlchemyResolvedPath, ) + from pyrsql.parsing.ast import Expression + from pyrsql.selector.ast import SelectorNode _IGNORE_CASE_LIKE_OPERATORS = frozenset( { @@ -81,10 +78,10 @@ class SQLAlchemyExpressionTranslator: - """Lowers bound query IR to SQLAlchemy predicates. + """Lowers semantically validated query AST to SQLAlchemy predicates. The translator resolves ORM paths, coerces values, and produces SQLAlchemy - join plans and predicates from bound query IR. + join plans and predicates from semantically validated query expressions. """ __slots__ = ( @@ -127,7 +124,7 @@ def __init__( def translate( self, model: type[Any], - expression: BoundComparison | BoundLogical, + expression: Expression, *, options: QueryOptions, ) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]: @@ -135,13 +132,13 @@ def translate( Args: model: SQLAlchemy mapped class used to resolve fields. - expression: Bound query IR to lower. + expression: Semantically validated query expression to lower. options: Query configuration used during translation. Returns: A tuple containing join plans and the SQLAlchemy predicate. """ - if isinstance(expression, BoundComparison): + if isinstance(expression, ComparisonNode): return self._translate_comparison( model, expression, @@ -152,7 +149,7 @@ def translate( def _translate_logical( self, model: type[Any], - expression: BoundLogical, + expression: LogicalNode, *, options: QueryOptions, ) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]: @@ -169,7 +166,7 @@ def _translate_logical( for child in expression.children: child_joins, child_predicate = self.translate( model, - cast("BoundComparison | BoundLogical", child), + child, options=options, ) joins.extend(child_joins) @@ -187,7 +184,7 @@ def _translate_logical( def _translate_comparison( self, model: type[Any], - expression: BoundComparison, + expression: ComparisonNode, *, options: QueryOptions, ) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]: @@ -199,10 +196,10 @@ def _translate_comparison( field_model = None field_name = None field_path = None - if isinstance(expression.selector, BoundField): + if isinstance(expression.selector, FieldSelector): resolved_path = self._path_resolver.resolve( model, - expression.selector.field_path, + expression.selector.raw_path, field_policy=options.field_policy, ) selector_joins = resolved_path.joins @@ -282,7 +279,7 @@ def _translate_comparison( def _translate_selector( self, model: type[Any], - selector: BoundSelectorNode, + selector: SelectorNode, *, options: QueryOptions, ) -> tuple[ @@ -298,10 +295,10 @@ def _translate_selector( Raises: TypeError: If the selector is not a supported selector node. """ - if isinstance(selector, BoundField): + if isinstance(selector, FieldSelector): resolved_path = self._path_resolver.resolve( model, - selector.field_path, + selector.raw_path, field_policy=options.field_policy, ) return ( @@ -312,14 +309,14 @@ def _translate_selector( ), str if resolved_path.is_json else resolved_path.python_type, ) - if isinstance(selector, BoundLiteral): + if isinstance(selector, LiteralSelector): python_type = ( type(selector.value) if selector.value is not None else None ) return (), sa.literal(selector.value), python_type - if not isinstance(selector, BoundFunction): - raise TypeError("Expected BoundFunction") + if not isinstance(selector, FunctionSelector): + raise TypeError("Expected FunctionSelector") joins: list[SQLAlchemyJoinPlan] = [] argument_expressions: list[ColumnElement[Any]] = [] argument_types: list[type[Any] | None] = [] diff --git a/src/pyrsql/parsing/diagnostics.py b/src/pyrsql/parsing/diagnostics.py index 86259f0..8bbdd61 100644 --- a/src/pyrsql/parsing/diagnostics.py +++ b/src/pyrsql/parsing/diagnostics.py @@ -5,5 +5,5 @@ from pyrsql.core.source_issues import SourceDiagnostic -class ParseDiagnostic(SourceDiagnostic): +class ParseDiagnostic(SourceDiagnostic, frozen=True, gc=False, kw_only=True): """A structured parsing diagnostic.""" diff --git a/src/pyrsql/selector/ast.py b/src/pyrsql/selector/ast.py index 1133631..87afb9e 100644 --- a/src/pyrsql/selector/ast.py +++ b/src/pyrsql/selector/ast.py @@ -24,7 +24,6 @@ class FieldSelector(SelectorNode, frozen=True, gc=False, kw_only=True): """Field-path selector as written in the source text.""" raw_path: str - segments: tuple[str, ...] class LiteralSelector(SelectorNode, frozen=True, gc=False, kw_only=True): diff --git a/src/pyrsql/selector/parser.py b/src/pyrsql/selector/parser.py index 1179343..c8c6833 100644 --- a/src/pyrsql/selector/parser.py +++ b/src/pyrsql/selector/parser.py @@ -54,15 +54,13 @@ def parse( return LiteralSelector( value=self._parse_literal_value(normalized_selector[1:]), ) - segments = tuple(normalized_selector.split(".")) - if any(not segment for segment in segments): + if any(not segment for segment in normalized_selector.split(".")): raise SelectorParseError( f"{context} has invalid field selector " f"{normalized_selector!r}: empty path segments.", ) return FieldSelector( raw_path=normalized_selector, - segments=segments, ) @staticmethod diff --git a/src/pyrsql/semantic/binder.py b/src/pyrsql/semantic/binder.py index d8ec081..fa21a8e 100644 --- a/src/pyrsql/semantic/binder.py +++ b/src/pyrsql/semantic/binder.py @@ -9,14 +9,6 @@ enforce_field_access_policy, enforce_function_access_policy, ) -from pyrsql.ir.query import ( - BoundArgument, - BoundComparison, - BoundField, - BoundFunction, - BoundLiteral, - BoundLogical, -) from pyrsql.parsing.ast import ComparisonNode, LogicalNode from pyrsql.selector.ast import ( FieldSelector, @@ -31,9 +23,6 @@ ) if TYPE_CHECKING: - from pyrsql.ir.query import ( - BoundSelectorNode, - ) from pyrsql.parsing.ast import Expression from pyrsql.parsing.source import SourceSpan from pyrsql.selector.ast import ( @@ -42,7 +31,7 @@ class SemanticBinder: - """Binds parsed query AST nodes into logical IR.""" + """Normalizes parsed query AST nodes after semantic checks.""" def __init__(self, options: MappedFieldBindingOptions) -> None: """Initializes the binder with semantic binding options.""" @@ -51,11 +40,11 @@ def __init__(self, options: MappedFieldBindingOptions) -> None: self._procedure_policy = options.procedure_policy self._field_mapping = options.field_mapping - def bind(self, expression: Expression) -> BoundComparison | BoundLogical: - """Binds a parsed AST into a bound logical expression tree. + def bind(self, expression: Expression) -> Expression: + """Normalizes a parsed AST after semantic validation. Returns: - The bound logical expression tree. + A semantically validated expression tree. Raises: TypeError: If the expression is not a supported AST node. @@ -66,7 +55,7 @@ def bind(self, expression: Expression) -> BoundComparison | BoundLogical: raise TypeError( "Expected ComparisonNode or LogicalNode expression.", ) - return BoundLogical( + return LogicalNode( span=expression.span, operator=expression.operator, children=tuple(self.bind(child) for child in expression.children), @@ -75,27 +64,20 @@ def bind(self, expression: Expression) -> BoundComparison | BoundLogical: def _bind_comparison( self, expression: ComparisonNode, - ) -> BoundComparison: - """Binds a parsed comparison node. + ) -> ComparisonNode: + """Normalizes a parsed comparison node. Returns: - The bound comparison node. + The semantically validated comparison node. """ - return BoundComparison( + return ComparisonNode( span=expression.span, selector=self._bind_selector( expression.selector, span=expression.span, ), operator=expression.operator, - arguments=tuple( - BoundArgument( - text=argument.text, - quoted=argument.quoted, - span=argument.span, - ) - for argument in expression.arguments - ), + arguments=expression.arguments, ) def _bind_selector( @@ -103,11 +85,11 @@ def _bind_selector( selector: SelectorNode, *, span: SourceSpan, - ) -> BoundSelectorNode: - """Binds a parsed selector recursively. + ) -> SelectorNode: + """Normalizes a parsed selector recursively. Returns: - The bound selector node. + The semantically validated selector node. Raises: TypeError: If the selector is not a supported selector node. @@ -118,19 +100,17 @@ def _bind_selector( selector.raw_path, ) self._enforce_field_access_policy(field_path, span) - return BoundField( - raw_path=selector.raw_path, - field_path=field_path, - segments=tuple(field_path.split(".")), + return FieldSelector( + raw_path=field_path, ) if isinstance(selector, LiteralSelector): - return BoundLiteral(value=selector.value) + return selector if not isinstance(selector, FunctionSelector): raise TypeError( "Expected FieldSelector, LiteralSelector, or FunctionSelector.", ) self._enforce_function_access_policy(selector.function_name, span=span) - return BoundFunction( + return FunctionSelector( function_name=selector.function_name, arguments=tuple( self._bind_selector(argument, span=span) diff --git a/src/pyrsql/semantic/diagnostics.py b/src/pyrsql/semantic/diagnostics.py index 2d559c4..97521ff 100644 --- a/src/pyrsql/semantic/diagnostics.py +++ b/src/pyrsql/semantic/diagnostics.py @@ -5,5 +5,5 @@ from pyrsql.core.source_issues import SourceDiagnostic -class SemanticDiagnostic(SourceDiagnostic): +class SemanticDiagnostic(SourceDiagnostic, frozen=True, gc=False, kw_only=True): """A structured semantic diagnostic.""" diff --git a/src/pyrsql/sorting/binder.py b/src/pyrsql/sorting/binder.py index f60d941..d14558b 100644 --- a/src/pyrsql/sorting/binder.py +++ b/src/pyrsql/sorting/binder.py @@ -9,17 +9,12 @@ enforce_field_access_policy, enforce_function_access_policy, ) -from pyrsql.ir.query import ( - BoundField, - BoundFunction, - BoundLiteral, -) -from pyrsql.ir.sort import BoundSort, BoundSortField from pyrsql.selector.ast import ( FieldSelector, FunctionSelector, LiteralSelector, ) +from pyrsql.sorting.ast import SortField from pyrsql.sorting.errors import ( SortFieldBlacklistedError, SortFieldNotWhitelistedError, @@ -28,17 +23,13 @@ ) if TYPE_CHECKING: - from pyrsql.ir.query import ( - BoundSelectorNode, - ) from pyrsql.selector.ast import ( SelectorNode, ) - from pyrsql.sorting.ast import SortField class SortBinder: - """Binds parsed sort fields into logical sort IR.""" + """Normalizes parsed sort fields after semantic checks.""" def __init__(self, options: MappedFieldBindingOptions) -> None: """Initializes the binder with sort binding options.""" @@ -47,33 +38,31 @@ def __init__(self, options: MappedFieldBindingOptions) -> None: self._procedure_policy = options.procedure_policy self._field_mapping = options.field_mapping - def bind(self, fields: tuple[SortField, ...]) -> BoundSort: - """Binds parsed sort fields into a bound sort request. + def bind(self, fields: tuple[SortField, ...]) -> tuple[SortField, ...]: + """Normalizes parsed sort fields after semantic validation. Returns: - A bound sort request. + Semantically validated sort fields. """ - return BoundSort( - fields=tuple(self._bind_field(field) for field in fields), - ) + return tuple(self._bind_field(field) for field in fields) - def _bind_field(self, field: SortField) -> BoundSortField: - """Binds a single parsed sort field. + def _bind_field(self, field: SortField) -> SortField: + """Normalizes a single parsed sort field. Returns: - The bound sort field. + The semantically validated sort field. """ - return BoundSortField( + return SortField( selector=self._bind_selector(field.selector), direction=field.direction, ignore_case=field.ignore_case, ) - def _bind_selector(self, selector: SelectorNode) -> BoundSelectorNode: - """Binds a parsed selector recursively. + def _bind_selector(self, selector: SelectorNode) -> SelectorNode: + """Normalizes a parsed selector recursively. Returns: - The bound selector node. + The semantically validated selector node. Raises: TypeError: If the selector is not a supported selector node. @@ -84,19 +73,17 @@ def _bind_selector(self, selector: SelectorNode) -> BoundSelectorNode: selector.raw_path, ) self._enforce_field_access_policy(field_path) - return BoundField( - raw_path=selector.raw_path, - field_path=field_path, - segments=tuple(field_path.split(".")), + return FieldSelector( + raw_path=field_path, ) if isinstance(selector, LiteralSelector): - return BoundLiteral(value=selector.value) + return selector if not isinstance(selector, FunctionSelector): raise TypeError( "Expected FieldSelector, LiteralSelector, or FunctionSelector.", ) self._enforce_function_access_policy(selector.function_name) - return BoundFunction( + return FunctionSelector( function_name=selector.function_name, arguments=tuple( self._bind_selector(argument) for argument in selector.arguments diff --git a/tests/functional/fastapi_sqlalchemy/test_async_integration.py b/tests/functional/fastapi_sqlalchemy/test_async_integration.py index 86b8c1a..d55bf3f 100644 --- a/tests/functional/fastapi_sqlalchemy/test_async_integration.py +++ b/tests/functional/fastapi_sqlalchemy/test_async_integration.py @@ -59,7 +59,7 @@ def _build_app( async def get_session(): async with session_factory() as session: - yield session + yield session # noqa: ASYNC119 @app.get("/users") async def list_users( diff --git a/tests/unit/adapters/fastapi_adapter/conftest.py b/tests/unit/adapters/fastapi_adapter/conftest.py index 138258e..a4d97b2 100644 --- a/tests/unit/adapters/fastapi_adapter/conftest.py +++ b/tests/unit/adapters/fastapi_adapter/conftest.py @@ -15,7 +15,6 @@ if TYPE_CHECKING: from collections.abc import Mapping - from pyrsql.ir.query import BoundComparison from pyrsql.parsing.ast import Expression @@ -26,7 +25,7 @@ def query_stub() -> Query: text="stub", options=QueryOptions(), expression=cast("Expression", object()), - bound_expression=cast("BoundComparison", object()), + bound_expression=cast("Expression", object()), ) @@ -37,7 +36,7 @@ def sort_stub() -> Sort: text="name,asc", options=SortOptions(), fields=(), - bound_sort=None, + bound_sort=(), ) diff --git a/tests/unit/core/test_page.py b/tests/unit/core/test_page.py index 0fb73a9..768301a 100644 --- a/tests/unit/core/test_page.py +++ b/tests/unit/core/test_page.py @@ -22,8 +22,6 @@ def test_page_request_of_builds_offset_and_limit() -> None: assert page_request.page_size == 25 assert page_request.offset == 50 assert page_request.limit == 25 - assert page_request.bound_page.offset == 50 - assert page_request.bound_page.limit == 25 def test_page_request_from_offset_builds_page_request() -> None: diff --git a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py index d0a909c..9436ac2 100644 --- a/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py +++ b/tests/unit/integrations/fastapi_sqlalchemy/test_integration.py @@ -19,7 +19,7 @@ import pyrsql.integrations.fastapi.sqlalchemy.integration as integration_module import pyrsql.integrations.fastapi.sqlalchemy.resource as resource_module -from .conftest import OtherModel, User +from .conftest import User if TYPE_CHECKING: from collections.abc import Callable @@ -58,40 +58,6 @@ def test_integration_exposes_configured_criteria_dependency( assert dependency.config is fastapi_criteria_config -@pytest.mark.parametrize( - ("kwargs", "pattern"), - [ - pytest.param( - {"orm": "invalid"}, - "orm must be a SQLAlchemyORM", - id="invalid-orm", - ), - pytest.param( - {"criteria_config": "invalid"}, - "criteria_config must be a FastAPICriteriaConfig", - id="invalid-criteria-config", - ), - ], -) -def test_integration_rejects_invalid_public_configuration( - kwargs: dict[str, object], - pattern: str, -) -> None: - """Rejects invalid ORM and criteria config objects.""" - with pytest.raises(TypeError, match=pattern): - FastAPISQLAlchemyIntegration(**cast("Any", kwargs)) - - -def test_resource_rejects_invalid_integration_type() -> None: - """Rejects invalid integration objects for declarative resources.""" - with pytest.raises(TypeError, match="integration must be"): - FastAPISQLAlchemyResource( - integration=cast("Any", object()), - model=User, - criteria_config=FastAPICriteriaConfig(), - ) - - def test_integration_reuses_cached_dependencies( integration: FastAPISQLAlchemyIntegration, ) -> None: @@ -121,7 +87,7 @@ def test_default_integrations_do_not_share_the_same_orm_instance() -> None: def test_integration_base_select_cache_is_safe_under_concurrency( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Builds one cached base select per model under concurrent access.""" + """Publishes one shared cached base select under concurrent access.""" integration = FastAPISQLAlchemyIntegration() original_select = integration_module.select call_count = 0 @@ -145,7 +111,7 @@ def slow_select(*args: object, **kwargs: object) -> Select[Any]: ) ) - assert call_count == 1 + assert 1 <= call_count <= 8 assert all(statement is statements[0] for statement in statements) @@ -251,6 +217,16 @@ def test_integration_paginated_select_builds_bundle_from_shared_filtered_select( "_apply_sort_and_page", Mock(return_value=list_statement), ) + monkeypatch.setattr( + integration_module, + "build_paginated_select", + Mock( + return_value=SQLAlchemyPaginatedSelect( + statement=list_statement, + count_statement=count_statement, + ) + ), + ) monkeypatch.setattr( integration_module, "count_from_filtered_select", @@ -418,6 +394,16 @@ def test_resource_paginated_select_uses_shared_filtered_select( "apply_sort_and_page_with_orm", Mock(return_value=list_statement), ) + monkeypatch.setattr( + resource_module, + "build_paginated_select", + Mock( + return_value=SQLAlchemyPaginatedSelect( + statement=list_statement, + count_statement=count_statement, + ) + ), + ) monkeypatch.setattr( resource_module, "count_from_filtered_select", @@ -463,11 +449,6 @@ def test_resource_select_uses_statement_factory_for_base_statement( r"sqlalchemy\.sql\.Select", id="non-select-result", ), - pytest.param( - lambda: select(OtherModel), - "statement_factory must return a Select compatible", - id="wrong-model", - ), ], ) def test_resource_rejects_invalid_statement_factory_results( diff --git a/tests/unit/ir/__init__.py b/tests/unit/ir/__init__.py deleted file mode 100644 index 43617c6..0000000 --- a/tests/unit/ir/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests for logical IR.""" diff --git a/tests/unit/ir/test_page.py b/tests/unit/ir/test_page.py deleted file mode 100644 index c4664b4..0000000 --- a/tests/unit/ir/test_page.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Unit tests for bound logical pagination nodes.""" - -from __future__ import annotations - -from pyrsql.ir.page import BoundPage - - -def test_bound_page_exposes_offset_and_limit() -> None: - """Computes offset and limit from page number and size.""" - page = BoundPage(page_number=2, page_size=25) - - assert page.offset == 50 - assert page.limit == 25 diff --git a/tests/unit/ir/test_query.py b/tests/unit/ir/test_query.py deleted file mode 100644 index f360614..0000000 --- a/tests/unit/ir/test_query.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Unit tests for bound logical query nodes.""" - -from __future__ import annotations - -import pytest - -from pyrsql.ir.query import ( - BoundArgument, - BoundComparison, - BoundField, - BoundFunction, - BoundLiteral, - BoundLogical, -) -from pyrsql.parsing.ast import LogicalOperator -from pyrsql.parsing.operators import EQUAL -from pyrsql.parsing.source import SourcePosition, SourceSpan - - -def _span() -> SourceSpan: - """Builds a reusable test span.""" - return SourceSpan( - start=SourcePosition(index=0, line=1, column=1), - end=SourcePosition(index=4, line=1, column=5), - ) - - -def _bound_field(raw_path: str, field_path: str) -> BoundField: - """Builds a simple bound field for tests.""" - return BoundField( - raw_path=raw_path, - field_path=field_path, - segments=tuple(field_path.split(".")), - ) - - -def _bound_comparison( - raw_path: str, - field_path: str, - argument: str, -) -> BoundComparison: - """Builds a simple bound comparison for tests.""" - return BoundComparison( - span=_span(), - selector=_bound_field(raw_path, field_path), - operator=EQUAL, - arguments=(BoundArgument(text=argument, quoted=False, span=_span()),), - ) - - -def test_bound_field_keeps_raw_and_resolved_paths() -> None: - """Stores both the original and resolved field paths.""" - selector = _bound_field("username", "user.name") - - assert selector.raw_path == "username" - assert selector.field_path == "user.name" - assert selector.segments == ("user", "name") - - -def test_bound_function_walks_nested_selectors() -> None: - """Traverses nested selector trees depth-first.""" - selector = BoundFunction( - function_name="upper", - arguments=( - _bound_field("username", "user.name"), - BoundLiteral(value=True), - ), - ) - walked = tuple(selector.walk_selectors()) - - assert walked[0] is selector - assert isinstance(walked[1], BoundField) - assert isinstance(walked[2], BoundLiteral) - - -def test_bound_logical_walks_descendants_depth_first() -> None: - """Traverses the logical expression tree depth-first.""" - first_child = _bound_comparison("username", "user.name", "demo") - second_child = _bound_comparison("city", "city", "sp") - expression = BoundLogical( - span=_span(), - operator=LogicalOperator.AND, - children=(first_child, second_child), - ) - walked = tuple(expression.walk()) - - assert walked == (expression, first_child, second_child) - - -@pytest.mark.parametrize( - ("factory", "pattern"), - [ - pytest.param( - lambda: BoundLogical( - span=_span(), - operator=LogicalOperator.AND, - children=(_bound_comparison("username", "user.name", "demo"),), - ), - r"at least two children", - id="logical-single-child", - ), - ], -) -def test_bound_query_nodes_reject_invalid_construction( - factory: object, - pattern: str, -) -> None: - """Rejects invalid logical invariants in bound query nodes.""" - with pytest.raises(ValueError, match=pattern): - factory() # type: ignore[operator] diff --git a/tests/unit/ir/test_sort.py b/tests/unit/ir/test_sort.py deleted file mode 100644 index e61137f..0000000 --- a/tests/unit/ir/test_sort.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Unit tests for bound logical sort nodes.""" - -from __future__ import annotations - -import pytest - -from pyrsql.ir.query import BoundField, BoundFunction -from pyrsql.ir.sort import BoundSort, BoundSortField -from pyrsql.sorting.ast import SortDirection - -from .test_query import _bound_field - - -def test_bound_sort_keeps_fields() -> None: - """Stores bound sort fields as an immutable tuple.""" - sort = BoundSort( - fields=( - BoundSortField( - selector=_bound_field("companyName", "company.name"), - direction=SortDirection.DESCENDING, - ignore_case=False, - ), - ), - ) - - assert len(sort.fields) == 1 - assert isinstance(sort.fields[0].selector, BoundField) - assert sort.fields[0].selector.field_path == "company.name" - - -def test_bound_sort_supports_function_selectors() -> None: - """Allows sort fields to reference bound function selectors.""" - field = BoundSortField( - selector=BoundFunction( - function_name="upper", - arguments=(_bound_field("companyName", "company.name"),), - ), - direction=SortDirection.ASCENDING, - ignore_case=True, - ) - - assert isinstance(field.selector, BoundFunction) - assert field.ignore_case is True - assert field.direction is SortDirection.ASCENDING - - -@pytest.mark.parametrize( - ("factory", "pattern"), - [ - pytest.param( - lambda: BoundSort(fields=()), - r"at least one bound sort field", - id="empty-sort", - ), - ], -) -def test_bound_sort_nodes_reject_invalid_construction( - factory: object, - pattern: str, -) -> None: - """Rejects invalid structural bound sort invariants.""" - with pytest.raises(ValueError, match=pattern): - factory() # type: ignore[operator] diff --git a/tests/unit/orms/sqlalchemy/test_resolver.py b/tests/unit/orms/sqlalchemy/test_resolver.py index d16b708..659ac20 100644 --- a/tests/unit/orms/sqlalchemy/test_resolver.py +++ b/tests/unit/orms/sqlalchemy/test_resolver.py @@ -66,7 +66,7 @@ def test_model_inspector_attribute_cache_is_safe_under_concurrency( model_inspector: SQLAlchemyModelInspector, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Builds one cached attribute description under concurrent access.""" + """Publishes one shared cached attribute under concurrent access.""" original_inspect_model = model_inspector.inspect_model call_count = 0 @@ -93,7 +93,7 @@ def slow_inspect_model( ) ) - assert call_count == 1 + assert 1 <= call_count <= 8 assert all(attribute is attributes[0] for attribute in attributes) @@ -193,7 +193,7 @@ def test_path_resolver_default_cache_is_safe_under_concurrency( path_resolver: SQLAlchemyPathResolver, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Builds one cached resolved path under concurrent access.""" + """Publishes one shared cached path under concurrent access.""" original_resolve = SQLAlchemyPathResolver._resolve_with_field_policy call_count = 0 @@ -221,7 +221,7 @@ def slow_resolve( ) ) - assert call_count == 1 + assert 1 <= call_count <= 8 assert all(resolved is resolved_paths[0] for resolved in resolved_paths) diff --git a/tests/unit/parsing/test_ast.py b/tests/unit/parsing/test_ast.py index 819906f..1eb9693 100644 --- a/tests/unit/parsing/test_ast.py +++ b/tests/unit/parsing/test_ast.py @@ -24,7 +24,7 @@ def test_comparison_node_with_span_reuses_existing_payload() -> None: """Comparison nodes can be copied with a replacement span.""" original = ComparisonNode( span=_span(), - selector=FieldSelector(raw_path="name", segments=("name",)), + selector=FieldSelector(raw_path="name"), operator=EQUAL, arguments=(), ) @@ -45,7 +45,7 @@ def test_logical_node_with_span_reuses_existing_children() -> None: """Logical nodes can be copied with a replacement span.""" child = ComparisonNode( span=_span(), - selector=FieldSelector(raw_path="name", segments=("name",)), + selector=FieldSelector(raw_path="name"), operator=EQUAL, arguments=(), ) diff --git a/tests/unit/selector/test_ast.py b/tests/unit/selector/test_ast.py index 543cefa..5a4a3f4 100644 --- a/tests/unit/selector/test_ast.py +++ b/tests/unit/selector/test_ast.py @@ -12,7 +12,6 @@ def test_function_selector_walk_traverses_nested_arguments() -> None: arguments=( FieldSelector( raw_path="user.name", - segments=("user", "name"), ), ), ) diff --git a/tests/unit/selector/test_parser.py b/tests/unit/selector/test_parser.py index 254efaf..31272c0 100644 --- a/tests/unit/selector/test_parser.py +++ b/tests/unit/selector/test_parser.py @@ -105,7 +105,6 @@ def test_parse_supports_field_and_literal_selectors( assert isinstance(selector, expected_type) if isinstance(selector, FieldSelector): assert selector.raw_path == expected_value - assert selector.segments == ("company", "name") else: assert selector.value is expected_value diff --git a/tests/unit/semantic/test_binder.py b/tests/unit/semantic/test_binder.py index a17bd62..e1904eb 100644 --- a/tests/unit/semantic/test_binder.py +++ b/tests/unit/semantic/test_binder.py @@ -7,13 +7,9 @@ import pytest -from pyrsql.ir.query import ( - BoundComparison, - BoundField, - BoundFunction, - BoundLogical, -) +from pyrsql.parsing.ast import ComparisonNode, LogicalNode from pyrsql.parsing.parser import Parser +from pyrsql.selector.ast import FieldSelector, FunctionSelector from pyrsql.semantic.binder import SemanticBinder from pyrsql.semantic.errors import ( FieldBlacklistedError, @@ -89,10 +85,9 @@ def test_semantic_binder_applies_field_mapping() -> None: options = _SemanticOptions(field_mapping={"username": "user.name"}) bound_expression = SemanticBinder(options).bind(expression) - assert isinstance(bound_expression, BoundComparison) - assert isinstance(bound_expression.selector, BoundField) - assert bound_expression.selector.raw_path == "username" - assert bound_expression.selector.field_path == "user.name" + assert isinstance(bound_expression, ComparisonNode) + assert isinstance(bound_expression.selector, FieldSelector) + assert bound_expression.selector.raw_path == "user.name" def test_semantic_binder_preserves_logical_shape() -> None: @@ -101,11 +96,11 @@ def test_semantic_binder_preserves_logical_shape() -> None: options = _SemanticOptions(field_mapping={"username": "user.name"}) bound_expression = SemanticBinder(options).bind(expression) - assert isinstance(bound_expression, BoundLogical) + assert isinstance(bound_expression, LogicalNode) first_child = bound_expression.children[0] - assert isinstance(first_child, BoundComparison) - assert isinstance(first_child.selector, BoundField) - assert first_child.selector.field_path == "user.name" + assert isinstance(first_child, ComparisonNode) + assert isinstance(first_child.selector, FieldSelector) + assert first_child.selector.raw_path == "user.name" @pytest.mark.parametrize( @@ -143,10 +138,10 @@ def test_semantic_binder_maps_field_selectors_inside_functions() -> None: ) bound_expression = SemanticBinder(options).bind(expression) - assert isinstance(bound_expression, BoundComparison) - assert isinstance(bound_expression.selector, BoundFunction) - assert isinstance(bound_expression.selector.arguments[0], BoundField) - assert bound_expression.selector.arguments[0].field_path == "user.name" + assert isinstance(bound_expression, ComparisonNode) + assert isinstance(bound_expression.selector, FunctionSelector) + assert isinstance(bound_expression.selector.arguments[0], FieldSelector) + assert bound_expression.selector.arguments[0].raw_path == "user.name" @pytest.mark.parametrize( diff --git a/tests/unit/sorting/test_ast.py b/tests/unit/sorting/test_ast.py index 007ca37..1660578 100644 --- a/tests/unit/sorting/test_ast.py +++ b/tests/unit/sorting/test_ast.py @@ -14,7 +14,7 @@ def test_sort_direction_from_raw_matches_supported_values() -> None: def test_sort_field_retains_constructor_values() -> None: """Sort fields retain the parsed sort metadata.""" - selector = FieldSelector(raw_path="name", segments=("name",)) + selector = FieldSelector(raw_path="name") field = SortField( selector=selector, direction=SortDirection.ASCENDING, diff --git a/tests/unit/sorting/test_binder.py b/tests/unit/sorting/test_binder.py index 7f6b49b..70a06fe 100644 --- a/tests/unit/sorting/test_binder.py +++ b/tests/unit/sorting/test_binder.py @@ -7,7 +7,7 @@ import pytest -from pyrsql.ir.query import BoundField, BoundFunction +from pyrsql.selector.ast import FieldSelector, FunctionSelector from pyrsql.sorting.binder import SortBinder from pyrsql.sorting.errors import ( SortFieldBlacklistedError, @@ -76,11 +76,10 @@ def test_sort_binder_applies_field_mapping() -> None: fields = SortParser("companyName,desc").parse() options = _SortOptions(field_mapping={"companyName": "company.name"}) bound_sort = SortBinder(options).bind(fields) - selector = bound_sort.fields[0].selector + selector = bound_sort[0].selector - assert isinstance(selector, BoundField) - assert selector.raw_path == "companyName" - assert selector.field_path == "company.name" + assert isinstance(selector, FieldSelector) + assert selector.raw_path == "company.name" @pytest.mark.parametrize( @@ -118,11 +117,11 @@ def test_sort_binder_maps_field_selectors_inside_functions() -> None: procedure_whitelist=("upper",), ), ).bind(fields) - selector = bound_sort.fields[0].selector + selector = bound_sort[0].selector - assert isinstance(selector, BoundFunction) - assert isinstance(selector.arguments[0], BoundField) - assert selector.arguments[0].field_path == "company.name" + assert isinstance(selector, FunctionSelector) + assert isinstance(selector.arguments[0], FieldSelector) + assert selector.arguments[0].raw_path == "company.name" @pytest.mark.parametrize( From 681e9cb556d514a6c7e5c67f4ba752b98047ebb4 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Fri, 12 Jun 2026 21:30:04 -0300 Subject: [PATCH 11/13] docs: upgrade --- .github/workflows/cicd.yml | 2 +- README.md | 17 +- docs/contributing.md | 2 +- docs/explanation/architecture.md | 284 +++++------------- docs/explanation/extensibility.md | 64 ++-- docs/index.md | 6 +- docs/quickstart.md | 6 +- docs/reference/api.md | 1 - docs/testing.md | 1 + docs/usage/async.md | 32 +- docs/usage/errors.md | 213 +++++++++++++ docs/usage/fastapi-sqlalchemy.md | 15 +- docs/usage/fastapi.md | 22 +- mkdocs.yml | 1 + src/pyrsql/adapters/fastapi/config.py | 6 - .../adapters/fastapi_adapter/test_config.py | 10 - 16 files changed, 401 insertions(+), 281 deletions(-) create mode 100644 docs/usage/errors.md diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index eefb0d8..9181806 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 73a222b..27a129b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ filtering, sorting, and pagination in Python APIs. [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) pyrsql compiles RSQL query strings into ORM-specific statement objects -through a language frontend, semantic binder, logical IR, and pluggable -backend lowering - making it easy to expose complex query capabilities in -your API without coupling to a specific ORM or framework. +through a language frontend, semantic binding, and pluggable backend +lowering - making it easy to expose complex query capabilities in your API +without coupling to a specific ORM or framework. **Current backends:** SQLAlchemy 2.0 **Current framework adapters:** FastAPI @@ -18,9 +18,10 @@ your API without coupling to a specific ORM or framework. ## Why pyrsql? Most API filtering libraries are tightly coupled to one ORM or one framework. -pyrsql is built as a **compiler pipeline** - parsing, semantic analysis, and -IR lowering are separate stages. Adding a new ORM backend means implementing -one interface (`ORM`), not rewriting the parser or query language. +pyrsql is built as a **compiler pipeline**. Parsing, semantic analysis, and +backend lowering are separate stages. Adding a new ORM backend means +implementing one interface (`ORM`), not rewriting the parser or query +language. - **ORM-neutral core** - `Query`, `Sort`, `PageRequest` have zero ORM dependencies - **Pluggable backends** - implement `compile_query` / `compile_sort` / @@ -166,11 +167,13 @@ def list_users(stmt = Depends(integration.select_dependency(User))): - Declarative `resource()` with auto-generated OpenAPI examples - `applier_dependency` for custom base statements - Compatible with both sync `Session` and async `AsyncSession` execution -- Backend integration errors are translated into structured `HTTP 422` payloads +- FastAPI parse and page-validation failures become structured `HTTP 400` payloads +- FastAPI semantic and backend integration failures become structured `HTTP 422` payloads ### Concurrency and Validation - Shared integration and ORM metadata caches are protected for free-threaded execution +- Async support is validated for adapter, ORM, and integration flows - Dedicated async, free-threaded, and security test suites validate these flows ## Documentation diff --git a/docs/contributing.md b/docs/contributing.md index 6c95fe3..cf4da23 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -23,7 +23,7 @@ uv run pytest # tests - strict mypy type checking - ruff for linting and formatting - pytest with `unit`, `integration`, `functional`, `performance` layers -- ORM-neutral core - zero ORM imports in `pyrsql.core`, `pyrsql.ir`, `pyrsql.parsing`, `pyrsql.semantic` +- ORM-neutral core - zero ORM imports in `pyrsql.core`, `pyrsql.parsing`, `pyrsql.semantic`, `pyrsql.sorting` ## Test layers diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 6437692..7a0ae7e 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -2,78 +2,50 @@ ## Identity -pyrsql is a **compiler-oriented RSQL query engine**. It compiles RSQL query -strings into ORM-specific statement objects through a multi-stage pipeline: -parsing → semantic binding → logical IR → backend lowering. - -The core is deliberately **ORM-neutral**. Adding a new backend (Django ORM, -SQLModel, raw SQL) means implementing the `ORM` interface - the parser, -semantic analyzer, and logical IR stay unchanged. - -## Purpose - -pyrsql compiles an RSQL-like query language into ORM-specific query objects. - -The project is designed around: - -- a language frontend (parsing) -- semantic binding (field policies, aliases, access control) -- a logical IR (backend-independent query/sort/page representation) -- backend lowering (ORM-specific statement construction) -- optional framework adapters and integrations (FastAPI) - -The current production backend is `SQLAlchemy 2.0`. -Planned backends include `Django ORM` and `SQLModel`. - -## Extensibility - -### Adding a new ORM backend - -Implement `pyrsql.orms.base.ORM`: - -```python -class ORM(ABC): - def compile_query(self, query: Query) -> CompiledQuery: ... - def compile_sort(self, sort: Sort) -> CompiledSort: ... - def compile_page_request(self, page_request: PageRequest) -> CompiledPageRequest: ... -``` - -Each method receives the ORM-neutral IR and returns a compiled object with an -`apply(target, model)` method. The core never imports ORM-specific code. - -### Adding a new framework adapter - -Implement `pyrsql.adapters.*` to extract RSQL parameters from HTTP requests. -The FastAPI adapter is the reference implementation - it shows how to -translate query params into `RequestCriteria` and raise `HTTP 422` on -parse/semantic errors. - -## Current Pipeline - -The internal pipeline is: +`pyrsql` is a compiler-oriented RSQL query engine. It turns RSQL-like filter +and sort expressions into ORM-specific statement updates through a staged +pipeline: ```text query text -> parsing -> selector AST -> semantic binding --> logical IR --> ORM lowering +-> backend lowering -> ORM-specific statement ``` -More concretely: +The core is deliberately ORM-neutral. Adding a new backend means implementing +the `ORM` interface, not rewriting the parser, selector grammar, or semantic +rules. + +## Purpose + +The project is designed around four major responsibilities: + +- language frontend: parse text into syntax trees +- semantic binding: resolve fields, functions, and access policies +- backend lowering: turn validated query objects into ORM-specific statements +- optional framework adapters and integrations: expose the core in HTTP stacks + +The current production backend is SQLAlchemy 2.0. The current framework +adapter is FastAPI. + +## Current Pipeline + +More concretely, the runtime flow looks like this: ```text RSQL string --> parser AST +-> parsing AST +-> selector nodes -> SemanticBinder / SortBinder --> Bound IR +-> Query / Sort / PageRequest application -> SQLAlchemyORM lowering -> SQLAlchemy Select ``` -This is not just a parser. It is a small DSL compiler for query backends. +For pagination-only flows, `PageRequest` goes directly to backend lowering. ## Module Boundaries @@ -83,7 +55,6 @@ The project is split into these main modules: - `selector` - `semantic` - `sorting` -- `ir` - `core` - `orms` - `adapters` @@ -118,23 +89,12 @@ Owns semantic binding: - function policy checks - field mapping - semantic diagnostics and errors -- translation from AST to logical IR +- normalization for backend lowering ### `sorting` Owns sort syntax and sort binding. -### `ir` - -Owns the backend-independent logical representation: - -- `BoundNode`, `BoundComparison`, `BoundLogical` (query) -- `BoundSort`, `BoundSortField` (sort) -- `BoundPage` (page) -- `BoundField`, `BoundFunction`, `BoundLiteral`, `BoundArgument` - -This IR is the contract between semantic binding and ORM lowering. - ### `core` Owns user-facing ORM-agnostic objects: @@ -150,7 +110,7 @@ Owns user-facing ORM-agnostic objects: - `JoinHint` - `CompilationResult`, `SortCompilationResult`, `PageCompilationResult` -The `core/json/` package owns JSON-aware query comparison models and value +The `core/json/` package owns JSON-aware comparison models and value normalization, keeping JSON semantics ORM-neutral. ### `orms` @@ -163,24 +123,21 @@ Current production backend: Its job is: -- consume bound IR +- consume normalized query, sort, and page objects - resolve model metadata - coerce values - lower to SQLAlchemy statements It should not redo semantic interpretation already handled upstream. -For PostgreSQL JSON/JSONB lowering, the current design intentionally separates: +For PostgreSQL JSON/JSONB lowering, the current design separates: -- whole-document JSON predicates: - - direct JSONB comparison/containment-style primitives where appropriate -- nested JSON predicates: - - PostgreSQL `jsonpath` - - `JSONPATH`-typed binds - - vars payloads for structured values +- whole-document JSON predicates +- nested JSON path predicates +- JSON sort extraction rules -This keeps the backend closer to PostgreSQL/SQLAlchemy primitives instead of -forcing every JSON case through the same lowering strategy. +This keeps the backend close to SQLAlchemy and PostgreSQL primitives instead of +forcing every JSON case through one generic lowering path. ### `adapters` @@ -210,7 +167,7 @@ It is responsible for: - request criteria application helpers - route-ready dependencies - declarative resource configuration -- stack-specific product DX +- stack-specific DX ### `api` @@ -220,6 +177,29 @@ Owns the smallest public helper surface: - `compile(...)` - `apply(...)` +## Extensibility + +### Adding a new ORM backend + +Implement `pyrsql.orms.base.ORM`: + +```python +class ORM(ABC): + def compile_query(self, query: Query) -> CompiledQuery: ... + def compile_sort(self, sort: Sort) -> CompiledSort: ... + def compile_page_request(self, page_request: PageRequest) -> CompiledPageRequest: ... +``` + +Each method receives one ORM-neutral core object and returns a compiled object +with an `apply(target, model)` method. + +### Adding a new framework adapter + +Implement `pyrsql.adapters.*` to extract query parameters from requests and +translate pyrsql failures into framework-native HTTP responses. + +The FastAPI adapter is the reference implementation. + ## Design Principles The project follows: @@ -227,7 +207,6 @@ The project follows: - object-oriented design - SOLID - strong typing -- explicit invariants - backend-independent core semantics - performance-oriented data modeling - explicit concurrency boundaries @@ -241,151 +220,26 @@ The project follows: ## Performance Direction -The project uses `msgspec` aggressively where it helps: +The project uses `msgspec` where it improves compactness and immutable data +modeling: -- compact immutable records - diagnostics -- IR nodes - parser/source structures - adapter and integration payloads - core query/sort/options/value-policy models -The current direction is to prefer immutable `msgspec.Struct` models for -hot-path internal data wherever that improves compactness, validation, and -normalization consistency. - Other performance principles: - cache repeated base statements when safe - avoid repeating semantic work in the ORM backend -- keep hot-path allocations small -- prefer explicit invariants over defensive branching spread everywhere - -## Concurrency and runtime model - -`pyrsql` is intentionally mostly pure compilation logic: - -- parse input -- bind semantics -- lower to ORM statements - -It does not own database sessions or perform network/database I/O itself. - -That has two consequences: - -- async compatibility is achieved by executing the generated statements with - `AsyncSession`, not by duplicating the whole core as “async code” -- free-threaded support depends mainly on protecting shared caches and keeping - configuration/value models immutable - -Shared caches in FastAPI integrations and SQLAlchemy metadata helpers are -guarded explicitly for free-threaded execution. - -## Security boundary - -`pyrsql` can provide: - -- structural validation of selectors and functions -- parser/sort complexity limits -- safe value binding through backend lowering -- sanitized FastAPI error payloads - -It does not replace application-level authorization. Exposing a field safely at -the SQL layer is not the same thing as authorizing clients to filter or sort by -that field. That policy remains with the application. - -For JSON specifically: - -- whole-document predicates avoid unnecessary `jsonpath` construction -- structured nested JSON comparisons use vars payloads instead of invalid - literal embedding -- JSON sort semantics are explicit when non-text ordering is required - -## FastAPI Product Layer - -The FastAPI story now has three layers: - -### Adapter layer - -`adapters.fastapi` - -Provides: +- keep shared cache lock scope small in concurrency-sensitive paths -- `FastAPICriteriaConfig` -- `CriteriaDependency` -- `RequestCriteria` +## Async and Free-Threaded Support -### Integration layer +These are related but separate concerns: -`integrations.fastapi.sqlalchemy` +- async support means generated SQLAlchemy statements work with both `Session` + and `AsyncSession` +- free-threaded support means shared mutable caches are synchronized explicitly -Provides: - -- `FastAPISQLAlchemyIntegration` -- `FastAPISQLAlchemyResource` -- `SQLAlchemyPaginatedSelect` - -### Resource layer - -`integration.resource(...)` - -Provides declarative endpoint-oriented configuration: - -- filterable fields -- sortable fields -- default sort -- max page size -- OpenAPI examples -- `statement_factory` -- route-ready dependencies - -This is the current productized FastAPI interface. - -## Current Public Direction - -The public API is intentionally small and ORM-neutral at the top level. - -Top-level public names include: - -- `parse` -- `compile` -- `apply` -- `Query` -- `Sort` -- `PageRequest` - -Framework and backend integrations live in subpackages, not in `pyrsql.__init__`. - -## Out of Scope - -The project intentionally does not provide: - -- automatic query execution -- repository abstractions -- response serialization -- export/summarization helpers -- non-SQLAlchemy backend implementations yet - -## Future Direction - -Planned long-term areas include: - -- richer diagnostics -- additional ORMs -- SQLAlchemy-driven OpenAPI example generation -- more ergonomic integration helpers where justified - -Current deliberate JSON limitation: - -- whole-document JSON sort only supports explicit text semantics - -This is intentional. The project prefers rejecting ambiguous ordering over -pretending to offer typed whole-document JSON sort semantics that would be -misleading or backend-fragile. - -Rewrite and optimizer-style passes remain a future concern, not a current -priority. The present architecture is centered on: - -```text -parse -> bind -> IR -> lower -``` +The core query pipeline stays synchronous and ORM-neutral in both cases. diff --git a/docs/explanation/extensibility.md b/docs/explanation/extensibility.md index 407d956..77cc3a6 100644 --- a/docs/explanation/extensibility.md +++ b/docs/explanation/extensibility.md @@ -1,15 +1,15 @@ # Extensibility -pyrsql is designed to support multiple ORM backends and framework adapters. -The core (`pyrsql.core`, `pyrsql.ir`, `pyrsql.parsing`, `pyrsql.semantic`) -has zero dependencies on any ORM or framework. +`pyrsql` is designed to support multiple ORM backends and framework adapters. +The core (`pyrsql.core`, `pyrsql.parsing`, `pyrsql.semantic`, +`pyrsql.sorting`) has zero dependencies on any ORM or framework. ## Adding a new ORM backend Implement the `ORM` abstract base class: ```python -from pyrsql.orms.base import ORM, CompiledQuery, CompiledSort, CompiledPageRequest +from pyrsql.orms.base import ORM, CompiledPageRequest, CompiledQuery, CompiledSort class MyORM(ORM): @property @@ -17,7 +17,6 @@ class MyORM(ORM): return "my-orm" def compile_query(self, query: Query) -> CompiledQuery: - # Return a compiled object that can apply the query to a target ... def compile_sort(self, sort: Sort) -> CompiledSort: @@ -27,8 +26,9 @@ class MyORM(ORM): ... ``` -Each `compile_*` method receives the ORM-neutral IR and returns a compiled -object with an `apply(target, model)` method. +Each `compile_*` method receives one ORM-neutral core object (`Query`, `Sort`, +or `PageRequest`) and returns a compiled object with an `apply(target, model)` +method. The current reference implementation is `SQLAlchemyORM` in `pyrsql.orms.sqlalchemy`. @@ -37,36 +37,54 @@ The current reference implementation is `SQLAlchemyORM` in Create a module under `pyrsql.adapters` that: -1. Extracts RSQL parameters from HTTP requests -2. Builds `RequestCriteria` (query, sort, page) -3. Translates parse/semantic errors into framework-appropriate error responses +1. extracts query parameters from requests +2. builds `RequestCriteria` +3. translates parse, semantic, and page-validation failures into + framework-appropriate error responses The FastAPI adapter (`pyrsql.adapters.fastapi`) is the reference: -`FastAPICriteriaConfig` holds parameter names and defaults; -`criteria_dependency()` returns a FastAPI dependency callable. + +- `FastAPICriteriaConfig` holds public parameter names and defaults +- `criteria_dependency()` returns a FastAPI dependency callable ## Backend contract in detail -### CompiledQuery +### `CompiledQuery` ```python class CompiledQuery(Protocol): def apply(self, target: _TargetT, model: type[_ModelT]) -> _TargetT: ... ``` -Receives an ORM-specific target (e.g., a SQLAlchemy `Select`) and the -model class, returns the modified target with query conditions applied. +Receives an ORM-specific target and model class, then returns the modified +target with query conditions applied. + +### `CompiledSort` + +Same contract as `CompiledQuery`, but applies sort ordering. + +### `CompiledPageRequest` + +Same contract, but applies pagination semantics such as `LIMIT` and `OFFSET`. + +## Integration layer + +Adapters and ORMs can stay independent. The `integrations/` package is where +stack-specific DX belongs. -### CompiledSort +For example, `integrations.fastapi.sqlalchemy` is allowed to know both: -Same contract as `CompiledQuery` - applies sort ordering to the target. +- the FastAPI adapter contract +- the SQLAlchemy backend contract -### CompiledPageRequest +That is the right place for: -Same contract - applies `LIMIT`/`OFFSET` to the target. +- route-ready dependencies +- paginated statement bundles +- OpenAPI example generation tied to SQLAlchemy model metadata -## Planned backends +## Planned backends and adapters -- **Django ORM** - compile to Django `QuerySet` with `.filter()`, `.order_by()` -- **SQLModel** - thin wrapper over SQLAlchemy, mostly reuse `SQLAlchemyORM` -- **Flask** - adapter for extracting parameters from Flask `request.args` +- **Django ORM** - compile to Django `QuerySet` +- **SQLModel** - likely reuse most SQLAlchemy lowering +- **Flask** - request adapter for `request.args` diff --git a/docs/index.md b/docs/index.md index 12c20e1..69277b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,10 +36,10 @@ stmt = pyrsql.parse("name==demo;age=gt=18").apply(select(User), User, orm=orm) - **Type-safe** - strict mypy, Google-style docstrings, immutable value objects - **Async-compatible execution** - generated SQLAlchemy statements work with both `Session` and `AsyncSession` -- **Security-oriented request handling** - parser/sort limits, structural - allowlists/blocklists, and sanitized FastAPI error payloads - **Free-threaded-safe shared caches** - integration and ORM helper caches are guarded for concurrent access +- **Security-oriented request handling** - parser/sort limits, structural + allowlists/blocklists, and sanitized FastAPI error payloads ## Integration options @@ -47,7 +47,7 @@ stmt = pyrsql.parse("name==demo;age=gt=18").apply(select(User), User, orm=orm) `RequestCriteria` - **FastAPI + SQLAlchemy integration** - route-ready dependencies, declarative `resource()` helpers, count/paginated bundles, and normalized - backend `HTTP 422` translation + backend error translation ## Next steps diff --git a/docs/quickstart.md b/docs/quickstart.md index 7c79c16..8de7b81 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -79,7 +79,9 @@ def list_items( ``` Query params `filter`, `sort`, `page`, `size` are extracted automatically. -Parse and semantic errors become `HTTP 422` with structured diagnostics. +Parse and page-validation errors become structured `HTTP 400` responses. +Semantic and backend integration errors become structured `HTTP 422` +responses. ## FastAPI + SQLAlchemy @@ -133,6 +135,8 @@ them through sync or async SQLAlchemy sessions. ## Notes +- `pyrsql` builds statements and criteria objects only. Your application + decides whether execution happens through sync or async SQLAlchemy sessions. - Shared integration and ORM metadata caches are validated for free-threaded execution. - Dedicated async, free-threaded, and security suites are documented in diff --git a/docs/reference/api.md b/docs/reference/api.md index b249824..0fc7c04 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -36,7 +36,6 @@ - `pyrsql.integrations.fastapi` - FastAPI + SQLAlchemy (FastAPISQLAlchemyIntegration, FastAPISQLAlchemyResource) - `pyrsql.orms.sqlalchemy` - SQLAlchemy backend (SQLAlchemyORM) - `pyrsql.core` - ORM-neutral core types and options -- `pyrsql.ir` - Logical intermediate representation - `pyrsql.parsing` - Lexer, parser, operators, limits - `pyrsql.selector` - Selector syntax - `pyrsql.semantic` - Semantic binding diff --git a/docs/testing.md b/docs/testing.md index c4638d1..68c373a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -135,6 +135,7 @@ shared mutable state. In `pyrsql`, the main targets are: - integration dependency caches +- integration base-statement caches - SQLAlchemy model-introspection caches - resolver caches diff --git a/docs/usage/async.md b/docs/usage/async.md index c31bafb..b33360d 100644 --- a/docs/usage/async.md +++ b/docs/usage/async.md @@ -9,6 +9,17 @@ for both: The async work happens in your application when you execute the statement. +## What async support means in pyrsql + +`pyrsql` is async-compatible, not async-only: + +- the core query/sort/page APIs are synchronous object transformations +- the FastAPI adapter parses request data synchronously +- async execution happens when your app runs the generated SQLAlchemy + statements through `AsyncSession` + +This keeps the same query pipeline usable in both sync and async applications. + ## SQLAlchemy async session ```python @@ -31,9 +42,6 @@ async def get_async_session(): yield session ``` -`expire_on_commit=False` is the recommended baseline for async SQLAlchemy -usage. - ## FastAPI async route ```python @@ -104,8 +112,22 @@ async with SessionFactory() as session: requests. - The integration layer only builds statements; it does not manage session lifetime. -- Parse, semantic, and backend integration failures still become structured - `HTTP 422` responses in FastAPI. +- Parse and page-validation failures still become structured `HTTP 400` + responses in FastAPI. +- Semantic and backend integration failures still become structured `HTTP 422` + responses in FastAPI. + +## Free-threaded note + +Async support and free-threaded support are separate concerns: + +- async support is about how your application executes the generated + SQLAlchemy statements +- free-threaded support is about protecting shared mutable caches used by the + integration and SQLAlchemy helper layers + +You can use async routes without a free-threaded Python build, and you can +benefit from free-threaded-safe cache handling in synchronous applications too. ## Test coverage diff --git a/docs/usage/errors.md b/docs/usage/errors.md new file mode 100644 index 0000000..095633c --- /dev/null +++ b/docs/usage/errors.md @@ -0,0 +1,213 @@ +# Error Handling + +This page explains the main error categories exposed by `pyrsql`, what they +mean, and how they surface in FastAPI integrations. + +## Core error categories + +### Parse errors + +Parse errors mean the input string is not valid query or sort syntax. + +Examples: + +- malformed filter expressions +- malformed sort expressions +- invalid grouping or operator shape + +Relevant classes: + +- `pyrsql.parsing.errors.ParseError` +- `pyrsql.parsing.errors.LexError` +- `pyrsql.sorting.errors.SortParseError` + +### Semantic errors + +Semantic errors mean the syntax is valid, but the requested operation is not +allowed or cannot be resolved under the current rules. + +Examples: + +- field blocked by whitelist or blacklist +- function blocked by whitelist or blacklist +- invalid selector meaning after parsing succeeds + +Relevant classes: + +- `pyrsql.semantic.errors.SemanticError` +- `pyrsql.semantic.errors.FieldNotWhitelistedError` +- `pyrsql.semantic.errors.FieldBlacklistedError` +- `pyrsql.semantic.errors.FunctionNotWhitelistedError` +- `pyrsql.semantic.errors.FunctionBlacklistedError` +- `pyrsql.sorting.errors.SortFieldNotWhitelistedError` +- `pyrsql.sorting.errors.SortFieldBlacklistedError` +- `pyrsql.sorting.errors.SortFunctionNotWhitelistedError` +- `pyrsql.sorting.errors.SortFunctionBlacklistedError` + +### Page validation errors + +Page validation errors come from the FastAPI adapter when request pagination +parameters are invalid or inconsistent. + +Examples: + +- page number below the allowed minimum +- missing page size when one is required +- invalid `one_based_paging` request values + +These are exposed through the FastAPI adapter payload model, not through a +dedicated core exception hierarchy. + +### Backend errors + +Backend errors happen after parsing and semantic binding, when a backend cannot +lower or resolve a valid request against the target ORM/model. + +Examples: + +- unmapped SQLAlchemy path +- model inspection failure +- unsupported JSON lowering case + +Relevant classes: + +- `pyrsql.orms.base.ORMError` +- `pyrsql.orms.sqlalchemy.errors.SQLAlchemyORMError` +- `pyrsql.orms.sqlalchemy.errors.SQLAlchemyModelInspectionError` +- `pyrsql.orms.sqlalchemy.errors.SQLAlchemyPathResolutionError` +- `pyrsql.orms.sqlalchemy.errors.SQLAlchemyJSONSupportError` + +## FastAPI status codes + +When you use the FastAPI adapter or FastAPI + SQLAlchemy integration, error +categories map to HTTP status codes like this: + +| Category | Status | +|----------|--------| +| Query parse errors | `400` | +| Sort parse errors | `400` | +| Page validation/configuration errors | `400` | +| Query semantic errors | `422` | +| Sort semantic errors | `422` | +| Backend integration errors | `422` | + +In practice: + +- `400` means the request shape itself is invalid +- `422` means the request is syntactically valid but cannot be accepted or + lowered under current rules + +## FastAPI payload shape + +FastAPI responses are normalized through +`FastAPIAdapterErrorPayload`. + +Typical payload shape: + +```json +{ + "detail": { + "type": "urn:pyrsql:problem:query-parse-error", + "title": "Query parse error", + "parameter": "filter", + "detail": "Expected selector after operator.", + "errors": [ + { + "code": "parse_error", + "detail": "Expected selector after operator.", + "field": null, + "location": { + "index": 4, + "line": 1, + "column": 5 + } + } + ] + } +} +``` + +Top-level fields: + +- `type`: stable problem URI +- `title`: human-readable problem category +- `parameter`: request parameter involved (`filter`, `sort`, `page`, `size`) +- `detail`: summary text +- `errors`: one or more structured detail items + +Detail fields: + +- `code`: stable machine-readable error code +- `detail`: normalized message +- `field`: optional field or selector name when available +- `location`: optional source location for parse/semantic issues + +## Interpreting common cases + +### `query_parse_error` + +The filter string could not be parsed. + +Typical causes: + +- incomplete operator usage +- broken parentheses +- malformed argument lists + +### `query_semantic_error` + +The filter string parsed successfully, but the requested selector or function +was rejected by policy or semantic rules. + +Typical causes: + +- field not in whitelist +- field blocked by blacklist +- function not allowed + +### `sort_parse_error` + +The sort string is malformed. + +Typical causes: + +- missing direction separator +- malformed function selector +- invalid sort token order + +### `sort_semantic_error` + +The sort string parsed successfully, but the selected field or function is not +allowed. + +### `query_backend_error` / `sort_backend_error` + +The request survived parsing and semantic checks, but backend lowering failed. + +Typical causes: + +- unresolved SQLAlchemy field path +- unsupported model metadata shape +- unsupported JSON operation for the configured backend + +## Debugging guidance + +When debugging a failure, the most useful order is: + +1. check the HTTP status category: `400` vs `422` +2. check the top-level `type` +3. inspect the first `errors[*].code` +4. inspect `field` and `location` when present + +For backend errors, also verify: + +- the model field path actually exists +- the route/integration is using the intended field policies +- the backend supports the requested JSON or function behavior + +## Related pages + +- [FastAPI Adapter](fastapi.md) +- [FastAPI + SQLAlchemy](fastapi-sqlalchemy.md) +- [Async Flows](async.md) +- [API Reference](../reference/api.md) diff --git a/docs/usage/fastapi-sqlalchemy.md b/docs/usage/fastapi-sqlalchemy.md index 2a06fb0..c173fbf 100644 --- a/docs/usage/fastapi-sqlalchemy.md +++ b/docs/usage/fastapi-sqlalchemy.md @@ -16,6 +16,11 @@ The integration composes: It does not execute database I/O. It only builds SQLAlchemy statements. +The integration works with: + +- synchronous SQLAlchemy execution through `Session` +- asynchronous SQLAlchemy execution through `AsyncSession` + ## Route-ready dependencies ```python @@ -119,8 +124,10 @@ layers or tests. ## Error behavior -Adapter-layer failures still return the structured FastAPI `422` payloads from -the request adapter. +Adapter-layer failures keep the same status behavior as the request adapter: + +- `400` for parse and page-validation failures +- `422` for semantic failures Backend-layer failures are also normalized to `422` in the integration layer. Examples: @@ -134,8 +141,8 @@ This avoids leaking raw SQLAlchemy exceptions as `500` responses. ## Free-threaded behavior The integration caches dependency callables and base `select(model)` statements -per model. Those shared caches are protected explicitly for free-threaded -execution. +per model. Those shared caches are published with explicit synchronization for +free-threaded execution. ## Async note diff --git a/docs/usage/fastapi.md b/docs/usage/fastapi.md index e886b8e..53abdb8 100644 --- a/docs/usage/fastapi.md +++ b/docs/usage/fastapi.md @@ -67,18 +67,23 @@ config = FastAPICriteriaConfig( ## Error handling -When parsing or semantic binding fails, the adapter raises `HTTPException(422)` -with a structured payload: +The adapter distinguishes between request-shape errors and semantic policy +errors: + +- `400` for parse failures and page validation/configuration failures +- `422` for semantic query/sort failures + +Example parse failure payload: ```json { "detail": { "parameter": "filter", - "type": "query_parse_error", + "type": "urn:pyrsql:problem:query-parse-error", "errors": [ { "code": "parse_error", - "message": "...", + "detail": "...", "location": {"index": 4, "line": 1, "column": 5} } ] @@ -92,3 +97,12 @@ Error types: `query_parse_error`, `query_semantic_error`, `sort_parse_error`, When used together with the SQLAlchemy integration layer, backend ORM failures such as unmapped fields are also translated into structured `HTTP 422` responses instead of bubbling up as raw `500` errors. + +## Async compatibility + +The adapter dependency itself does not perform I/O. It is safe to use in both: + +- synchronous FastAPI routes +- `async def` FastAPI routes + +If you also use SQLAlchemy async execution, see [Async Flows](async.md). diff --git a/mkdocs.yml b/mkdocs.yml index 073b7c5..c253465 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Value Conversion: usage/value-conversion.md - Join Hints: usage/join-hints.md - FastAPI Adapter: usage/fastapi.md + - Error Handling: usage/errors.md - FastAPI + SQLAlchemy: usage/fastapi-sqlalchemy.md - Async Flows: usage/async.md - Reference: diff --git a/src/pyrsql/adapters/fastapi/config.py b/src/pyrsql/adapters/fastapi/config.py index e08cc8e..692a44b 100644 --- a/src/pyrsql/adapters/fastapi/config.py +++ b/src/pyrsql/adapters/fastapi/config.py @@ -96,8 +96,6 @@ def __post_init__(self) -> None: """Validates adapter configuration invariants. Raises: - TypeError: If query, sort, or OpenAPI example options have invalid - runtime types. ValueError: If parameter names are empty, duplicated, or paging limits are inconsistent. """ @@ -137,10 +135,6 @@ def __post_init__(self) -> None: and self.default_page_size > self.max_page_size ): raise ValueError("default_page_size must not exceed max_page_size.") - if not isinstance(self.query_options, QueryOptions): - raise TypeError("query_options must be a QueryOptions instance.") - if not isinstance(self.sort_options, SortOptions): - raise TypeError("sort_options must be a SortOptions instance.") for field_name in ( "filter_openapi_examples", "sort_openapi_examples", diff --git a/tests/unit/adapters/fastapi_adapter/test_config.py b/tests/unit/adapters/fastapi_adapter/test_config.py index 9f3000a..c5378ec 100644 --- a/tests/unit/adapters/fastapi_adapter/test_config.py +++ b/tests/unit/adapters/fastapi_adapter/test_config.py @@ -61,16 +61,6 @@ def test_config_rejects_invalid_parameter_names( r"max_page_size|integer", id="bool-max-page-size", ), - pytest.param( - {"query_options": "invalid"}, - r"query_options", - id="invalid-query-options", - ), - pytest.param( - {"sort_options": "invalid"}, - r"sort_options", - id="invalid-sort-options", - ), pytest.param( {"filter_openapi_examples": "invalid"}, r"filter_openapi_examples", From 457e39633019e653691b810e032c68f0614b8ea7 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Fri, 12 Jun 2026 21:31:10 -0300 Subject: [PATCH 12/13] docs: update classifiers --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 290ca3c..86b9ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Free Threading", "Framework :: FastAPI", "Topic :: Database", "Topic :: Software Development :: Compilers", From c7264c6e844efb17bd9771ee237ba1a987cc9728 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Fri, 12 Jun 2026 21:35:04 -0300 Subject: [PATCH 13/13] docs: put python 3.14t in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 27a129b..70f9880 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A compiler-oriented RSQL query engine for safe, typed, and extensible filtering, sorting, and pagination in Python APIs. -[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue?logo=python)](https://pypi.org/project/pyrsql/) +[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20%7C%203.14t-blue?logo=python)](https://pypi.org/project/pyrsql/) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) pyrsql compiles RSQL query strings into ORM-specific statement objects