From 876ecc8f2e866c93596c88fcc614779cc3a63494 Mon Sep 17 00:00:00 2001 From: surister Date: Tue, 14 Oct 2025 11:05:41 +0200 Subject: [PATCH 01/23] Update pyproject.toml --- pyproject.toml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 08b0d321..563d36c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ +[build-system] +requires = ["hatchling >= 1.26"] +build-backend = "hatchling.build" + +[project] +name = "crate-python" +dynamic = ["version"] +description = "CrateDB Python Client" +authors = [{ name = "Crate.io", email = "office@crate.io" }] +requires-python = ">=3.10" +readme = "README.md" +license = { file = "LICENSE.md"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Database", +] +dependencies = [ + +] + +[dependency-groups] +dev = [ +] + + [tool.mypy] mypy_path = "src" packages = [ From 3651e565fa7dc74a18673c3df1070cfa81d5cc78 Mon Sep 17 00:00:00 2001 From: surister Date: Tue, 14 Oct 2025 15:06:28 +0200 Subject: [PATCH 02/23] Add deps and metadata --- pyproject.toml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 563d36c7..1a496054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,14 +2,20 @@ requires = ["hatchling >= 1.26"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +packages = ["src/crate"] + +[tool.hatch.version] +path = "src/crate/client/__init__.py" + [project] name = "crate-python" dynamic = ["version"] description = "CrateDB Python Client" authors = [{ name = "Crate.io", email = "office@crate.io" }] requires-python = ">=3.10" -readme = "README.md" -license = { file = "LICENSE.md"} +readme = "README.rst" +license = { file = "LICENSE"} classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -26,11 +32,18 @@ classifiers = [ "Topic :: Database", ] dependencies = [ - + "orjson>=3.11.3", + "urllib3>=2.5.0", ] [dependency-groups] dev = [ + "certifi>=2025.10.5", + "pytest>=8.4.2", + "pytz>=2025.2", + "setuptools>=80.9.0", + "stopit>=1.1.2", + "verlib2>=0.3.1", ] From 45d8140bfc475a74f9818cff881daff7445559ac Mon Sep 17 00:00:00 2001 From: surister Date: Tue, 14 Oct 2025 17:00:17 +0200 Subject: [PATCH 03/23] Add proper error formating to `BlobException` --- src/crate/client/exceptions.py | 2 +- tests/client/test_exceptions.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/crate/client/exceptions.py b/src/crate/client/exceptions.py index 3833eecc..5e99126b 100644 --- a/src/crate/client/exceptions.py +++ b/src/crate/client/exceptions.py @@ -86,7 +86,7 @@ def __init__(self, table, digest): self.digest = digest def __str__(self): - return "{table}/{digest}".format(table=self.table, digest=self.digest) + return f"{self.__class__.__qualname__}('{self.table}/{self.digest})'" class DigestNotFoundException(BlobException): diff --git a/tests/client/test_exceptions.py b/tests/client/test_exceptions.py index cb91e1a9..8b385774 100644 --- a/tests/client/test_exceptions.py +++ b/tests/client/test_exceptions.py @@ -1,13 +1,16 @@ -import unittest - from crate.client import Error +from crate.client.exceptions import BlobException + + +def test_error_with_msg(): + err = Error("foo") + assert str(err) == "foo" -class ErrorTestCase(unittest.TestCase): - def test_error_with_msg(self): - err = Error("foo") - self.assertEqual(str(err), "foo") +def test_error_with_error_trace(): + err = Error("foo", error_trace="### TRACE ###") + assert str(err), "foo\n### TRACE ###" - def test_error_with_error_trace(self): - err = Error("foo", error_trace="### TRACE ###") - self.assertEqual(str(err), "foo\n### TRACE ###") +def test_blob_exception(): + err = BlobException(table="sometable", digest="somedigest") + assert str(err) == "BlobException('sometable/somedigest)'" \ No newline at end of file From 15d53ec75a34446aca161deaa01ec51fe1cb5cc1 Mon Sep 17 00:00:00 2001 From: surister Date: Tue, 14 Oct 2025 19:50:59 +0200 Subject: [PATCH 04/23] Migrate test_cursor to pytest format. Mocking a connection is also simplified with a fixture. Aditionally two more tests were added. --- tests/client/test_cursor.py | 724 +++++++++++++++++------------------- tests/conftest.py | 18 + 2 files changed, 362 insertions(+), 380 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index 7f1a9f2f..b8054128 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -21,8 +21,10 @@ import datetime from ipaddress import IPv4Address -from unittest import TestCase -from unittest.mock import MagicMock +from unittest import TestCase, mock +import pytest + +from crate.client.exceptions import ProgrammingError try: import zoneinfo @@ -37,412 +39,374 @@ from crate.testing.util import ClientMocked -class CursorTest(TestCase): - @staticmethod - def get_mocked_connection(): - client = MagicMock(spec=Client) - return connect(client=client) - - def test_create_with_timezone_as_datetime_object(self): - """ - The cursor can return timezone-aware `datetime` objects when requested. - Switching the time zone at runtime on the cursor object is possible. - Here: Use a `datetime.timezone` instance. - """ - - connection = self.get_mocked_connection() - - tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") - cursor = connection.cursor(time_zone=tz_mst) - - self.assertEqual(cursor.time_zone.tzname(None), "MST") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=25200) - ) +def test_create_with_timezome_as_datetime_object(mocked_connection): + """ + The cursor can return timezone-aware `datetime` objects when requested. + Switching the time zone at runtime on the cursor object is possible. + Here: Use a `datetime.timezone` instance. + """ + tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") + cursor = mocked_connection.cursor(time_zone=tz_mst) + + assert cursor.time_zone.tzname(None) == "MST" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=25200) + + cursor.time_zone = datetime.timezone.utc + + assert cursor.time_zone.tzname(None) == "UTC" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(0) + + +def test_create_with_timezone_as_pytz_object(mocked_connection): + """ + The cursor can return timezone-aware `datetime` objects when requested. + Here: Use a `pytz.timezone` instance. + """ + + cursor = mocked_connection.cursor(time_zone=pytz.timezone("Australia/Sydney")) + assert cursor.time_zone.tzname(None) == "Australia/Sydney" + + # Apparently, when using `pytz`, the timezone object does not return + # an offset. Nevertheless, it works, as demonstrated per doctest in + # `cursor.txt`. + assert cursor.time_zone.utcoffset(None) is None + + +def test_create_with_timezone_as_zoneinfo_object(mocked_connection): + """ + The cursor can return timezone-aware `datetime` objects when requested. + Here: Use a `zoneinfo.ZoneInfo` instance. + """ + cursor = mocked_connection.cursor( + time_zone=zoneinfo.ZoneInfo("Australia/Sydney") + ) + assert cursor.time_zone.key == "Australia/Sydney" + + +def test_create_with_timezone_as_utc_offset_success(mocked_connection): + """ + Verify the cursor can return timezone-aware `datetime` objects when requested. + Here: Use a UTC offset in string format. + """ + + cursor = mocked_connection.cursor(time_zone="+0530") + assert cursor.time_zone.tzname(None) == "+0530" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=19800) + + cursor = mocked_connection.cursor(time_zone="-1145") + assert cursor.time_zone.tzname(None) == "-1145" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(days=-1, seconds=44100) + + +def test_create_with_timezone_as_utc_offset_failure(mocked_connection): + """ + Verify the cursor trips when trying to use invalid UTC offset strings. + """ + + with pytest.raises(ValueError) as err: + mocked_connection.cursor(time_zone="foobar") + assert err == \ + "Time zone 'foobar' is given in invalid UTC offset format" + + with pytest.raises(ValueError) as err: + mocked_connection.cursor(time_zone="+abcd") + assert err == \ + "Time zone '+abcd' is given in invalid UTC offset format: " + \ + "invalid literal for int() with base 10: '+ab'" + + +def test_create_with_timezone_connection_cursor_precedence(mocked_connection): + """ + Verify that the time zone specified on the cursor object instance + takes precedence over the one specified on the connection instance. + """ + connection = connect( + client=mocked_connection.client, + time_zone=pytz.timezone("Australia/Sydney") + ) + cursor = connection.cursor(time_zone="+0530") + assert cursor.time_zone.tzname(None) == "+0530" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=19800) + + +def test_execute_with_args(mocked_connection): + """ + Verify that `cursor.execute` is called with the right parameters. + """ + cursor = mocked_connection.cursor() + statement = "select * from locations where position = ?" + cursor.execute(statement, 1) + mocked_connection.client.sql.assert_called_once_with(statement, 1, None) + + +def test_execute_with_bulk_args(mocked_connection): + """ + Verify that `cursor.execute` is called with the right parameters + when passing `bulk_parameters`. + """ + cursor = mocked_connection.cursor() + statement = "select * from locations where position = ?" + cursor.execute(statement, bulk_parameters=[[1]]) + mocked_connection.client.sql.assert_called_once_with(statement, None, [[1]]) + + +def test_execute_custom_converter(mocked_connection): + """ + Verify that a custom converter is correctly applied when passed to a cursor. + """ + # Extends the DefaultTypeConverter + converter = DefaultTypeConverter( + { + DataType.BIT: lambda value: value is not None + and int(value[2:-1], 2) + or None + } + ) + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [4, 5, 11, 25], + "cols": ["name", "address", "timestamp", "bitmask"], + "rows": [ + ["foo", "10.10.10.1", 1658167836758, "B'0110'"], + [None, None, None, None], + ], + "rowcount": 1, + "duration": 123, + } + + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + cursor.execute("") + result = cursor.fetchall() - cursor.time_zone = datetime.timezone.utc - self.assertEqual(cursor.time_zone.tzname(None), "UTC") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(0) - ) - - def test_create_with_timezone_as_pytz_object(self): - """ - The cursor can return timezone-aware `datetime` objects when requested. - Here: Use a `pytz.timezone` instance. - """ - connection = self.get_mocked_connection() - cursor = connection.cursor(time_zone=pytz.timezone("Australia/Sydney")) - self.assertEqual(cursor.time_zone.tzname(None), "Australia/Sydney") - - # Apparently, when using `pytz`, the timezone object does not return - # an offset. Nevertheless, it works, as demonstrated per doctest in - # `cursor.txt`. - self.assertEqual(cursor.time_zone.utcoffset(None), None) - - def test_create_with_timezone_as_zoneinfo_object(self): - """ - The cursor can return timezone-aware `datetime` objects when requested. - Here: Use a `zoneinfo.ZoneInfo` instance. - """ - connection = self.get_mocked_connection() - cursor = connection.cursor( - time_zone=zoneinfo.ZoneInfo("Australia/Sydney") - ) - self.assertEqual(cursor.time_zone.key, "Australia/Sydney") - - def test_create_with_timezone_as_utc_offset_success(self): - """ - The cursor can return timezone-aware `datetime` objects when requested. - Here: Use a UTC offset in string format. - """ - connection = self.get_mocked_connection() - cursor = connection.cursor(time_zone="+0530") - self.assertEqual(cursor.time_zone.tzname(None), "+0530") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=19800) - ) - - connection = self.get_mocked_connection() - cursor = connection.cursor(time_zone="-1145") - self.assertEqual(cursor.time_zone.tzname(None), "-1145") - self.assertEqual( - cursor.time_zone.utcoffset(None), - datetime.timedelta(days=-1, seconds=44100), - ) - - def test_create_with_timezone_as_utc_offset_failure(self): - """ - Verify the cursor trips when trying to use invalid UTC offset strings. - """ - connection = self.get_mocked_connection() - with self.assertRaises(ValueError) as ex: - connection.cursor(time_zone="foobar") - self.assertEqual( - str(ex.exception), - "Time zone 'foobar' is given in invalid UTC offset format", - ) - - connection = self.get_mocked_connection() - with self.assertRaises(ValueError) as ex: - connection.cursor(time_zone="+abcd") - self.assertEqual( - str(ex.exception), - "Time zone '+abcd' is given in invalid UTC offset format: " - "invalid literal for int() with base 10: '+ab'", - ) - - def test_create_with_timezone_connection_cursor_precedence(self): - """ - Verify that the time zone specified on the cursor object instance - takes precedence over the one specified on the connection instance. - """ - client = MagicMock(spec=Client) - connection = connect( - client=client, time_zone=pytz.timezone("Australia/Sydney") - ) - cursor = connection.cursor(time_zone="+0530") - self.assertEqual(cursor.time_zone.tzname(None), "+0530") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=19800) - ) - - def test_execute_with_args(self): - client = MagicMock(spec=Client) - conn = connect(client=client) - c = conn.cursor() - statement = "select * from locations where position = ?" - c.execute(statement, 1) - client.sql.assert_called_once_with(statement, 1, None) - conn.close() - - def test_execute_with_bulk_args(self): - client = MagicMock(spec=Client) - conn = connect(client=client) - c = conn.cursor() - statement = "select * from locations where position = ?" - c.execute(statement, bulk_parameters=[[1]]) - client.sql.assert_called_once_with(statement, None, [[1]]) - conn.close() - - def test_execute_with_converter(self): - client = ClientMocked() - conn = connect(client=client) - - # Use the set of data type converters from `DefaultTypeConverter` - # and add another custom converter. - converter = DefaultTypeConverter( - { - DataType.BIT: lambda value: value is not None - and int(value[2:-1], 2) - or None - } - ) - - # Create a `Cursor` object with converter. - c = conn.cursor(converter=converter) - - # Make up a response using CrateDB data types `TEXT`, `IP`, - # `TIMESTAMP`, `BIT`. - conn.client.set_next_response( - { - "col_types": [4, 5, 11, 25], - "cols": ["name", "address", "timestamp", "bitmask"], - "rows": [ - ["foo", "10.10.10.1", 1658167836758, "B'0110'"], - [None, None, None, None], - ], - "rowcount": 1, - "duration": 123, - } - ) - - c.execute("") - result = c.fetchall() - self.assertEqual( - result, + assert result == [ [ - [ - "foo", - IPv4Address("10.10.10.1"), - datetime.datetime( - 2022, - 7, - 18, - 18, - 10, - 36, - 758000, - tzinfo=datetime.timezone.utc, - ), - 6, - ], - [None, None, None, None], + "foo", + IPv4Address("10.10.10.1"), + datetime.datetime( + 2022, + 7, + 18, + 18, + 10, + 36, + 758000, + tzinfo=datetime.timezone.utc, + ), + 6, ], - ) - - conn.close() - - def test_execute_with_converter_and_invalid_data_type(self): - client = ClientMocked() - conn = connect(client=client) - converter = DefaultTypeConverter() - - # Create a `Cursor` object with converter. - c = conn.cursor(converter=converter) - - # Make up a response using CrateDB data types `TEXT`, `IP`, - # `TIMESTAMP`, `BIT`. - conn.client.set_next_response( - { - "col_types": [999], - "cols": ["foo"], - "rows": [ - ["n/a"], - ], - "rowcount": 1, - "duration": 123, - } - ) - - c.execute("") - with self.assertRaises(ValueError) as ex: - c.fetchone() - self.assertEqual(ex.exception.args, ("999 is not a valid DataType",)) - - def test_execute_array_with_converter(self): - client = ClientMocked() - conn = connect(client=client) - converter = DefaultTypeConverter() - cursor = conn.cursor(converter=converter) - - conn.client.set_next_response( - { - "col_types": [4, [100, 5]], - "cols": ["name", "address"], - "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]], - "rowcount": 1, - "duration": 123, - } - ) - + [None, None, None, None], + ] + + +def test_execute_with_converter_and_invalid_data_type(mocked_connection): + converter = DefaultTypeConverter() + + # Create a `Cursor` object with converter. + cursor = mocked_connection.cursor(converter=converter) + + response = { + "col_types": [999], + "cols": ["foo"], + "rows": [ + ["n/a"], + ], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + cursor.execute("") + with pytest.raises(ValueError) as e: + cursor.fetchone() + assert e.exception.args == "999 is not a valid DataType" + + +def test_execute_array_with_converter(mocked_connection): + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [4, [100, 5]], + "cols": ["name", "address"], + "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): cursor.execute("") result = cursor.fetchone() - self.assertEqual( - result, - [ - "foo", - [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")], - ], - ) - - def test_execute_array_with_converter_and_invalid_collection_type(self): - client = ClientMocked() - conn = connect(client=client) - converter = DefaultTypeConverter() - cursor = conn.cursor(converter=converter) - - # Converting collections only works for `ARRAY`s. (ID=100). - # When using `DOUBLE` (ID=6), it should croak. - conn.client.set_next_response( - { - "col_types": [4, [6, 5]], - "cols": ["name", "address"], - "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]], - "rowcount": 1, - "duration": 123, - } - ) + assert result == [ + "foo", + [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")], + ] + + +def test_execute_array_with_converter_and_invalid_collection_type(mocked_connection): + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [4, [6, 5]], + "cols": ["name", "address"], + "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]], + "rowcount": 1, + "duration": 123, + } + # Converting collections only works for `ARRAY`s. (ID=100). + # When using `DOUBLE` (ID=6), it should raise an Exception. + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): cursor.execute("") - - with self.assertRaises(ValueError) as ex: + with pytest.raises(ValueError) as e: cursor.fetchone() - self.assertEqual( - ex.exception.args, - ("Data type 6 is not implemented as collection type",), - ) - - def test_execute_nested_array_with_converter(self): - client = ClientMocked() - conn = connect(client=client) - converter = DefaultTypeConverter() - cursor = conn.cursor(converter=converter) - - conn.client.set_next_response( - { - "col_types": [4, [100, [100, 5]]], - "cols": ["name", "address_buckets"], - "rows": [ - [ - "foo", - [ - ["10.10.10.1", "10.10.10.2"], - ["10.10.10.3"], - [], - None, - ], - ] - ], - "rowcount": 1, - "duration": 123, - } - ) + assert e.exception.args == "Data type 6 is not implemented as collection type" - cursor.execute("") - result = cursor.fetchone() - self.assertEqual( - result, + +def test_execute_nested_array_with_converter(mocked_connection): + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [4, [100, [100, 5]]], + "cols": ["name", "address_buckets"], + "rows": [ [ "foo", [ - [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")], - [IPv4Address("10.10.10.3")], + ["10.10.10.1", "10.10.10.2"], + ["10.10.10.3"], [], None, ], - ], - ) - - def test_executemany_with_converter(self): - client = ClientMocked() - conn = connect(client=client) - converter = DefaultTypeConverter() - cursor = conn.cursor(converter=converter) - - conn.client.set_next_response( - { - "col_types": [4, 5], - "cols": ["name", "address"], - "rows": [["foo", "10.10.10.1"]], - "rowcount": 1, - "duration": 123, - } - ) + ] + ], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + cursor.execute("") + result = cursor.fetchone() + assert result == [ + "foo", + [ + [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")], + [IPv4Address("10.10.10.3")], + [], + None, + ], + ] + + +def test_executemany_with_converter(mocked_connection): + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [4, 5], + "cols": ["name", "address"], + "rows": [["foo", "10.10.10.1"]], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): cursor.executemany("", []) result = cursor.fetchall() # ``executemany()`` is not intended to be used with statements # returning result sets. The result will always be empty. - self.assertEqual(result, []) - - def test_execute_with_timezone(self): - client = ClientMocked() - conn = connect(client=client) - - # Create a `Cursor` object with `time_zone`. - tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") - c = conn.cursor(time_zone=tz_mst) - - # Make up a response using CrateDB data type `TIMESTAMP`. - conn.client.set_next_response( - { - "col_types": [4, 11], - "cols": ["name", "timestamp"], - "rows": [ - ["foo", 1658167836758], - [None, None], - ], - } - ) - + assert result == [] + + +def test_execute_with_timezone(mocked_connection): + # Create a `Cursor` object with `time_zone`. + tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") + cursor = mocked_connection.cursor(time_zone=tz_mst) + + # Make up a response using CrateDB data type `TIMESTAMP`. + response = { + "col_types": [4, 11], + "cols": ["name", "timestamp"], + "rows": [ + ["foo", 1658167836758], + [None, None], + ], + } + with mock.patch.object(mocked_connection.client, 'sql', return_value=response): # Run execution and verify the returned `datetime` object is # timezone-aware, using the designated timezone object. - c.execute("") - result = c.fetchall() - self.assertEqual( - result, + cursor.execute("") + result = cursor.fetchall() + assert result == [ [ - [ - "foo", - datetime.datetime( - 2022, - 7, - 19, - 1, - 10, - 36, - 758000, - tzinfo=datetime.timezone( - datetime.timedelta(seconds=25200), "MST" - ), + "foo", + datetime.datetime( + 2022, + 7, + 19, + 1, + 10, + 36, + 758000, + tzinfo=datetime.timezone( + datetime.timedelta(seconds=25200), "MST" ), - ], - [ - None, - None, - ], + ), ], - ) - self.assertEqual(result[0][1].tzname(), "MST") + [ + None, + None, + ], + ] + + assert result[0][1].tzname() == "MST" # Change timezone and verify the returned `datetime` object is using it. - c.time_zone = datetime.timezone.utc - c.execute("") - result = c.fetchall() - self.assertEqual( - result, + cursor.time_zone = datetime.timezone.utc + cursor.execute("") + result = cursor.fetchall() + assert result == [ [ - [ - "foo", - datetime.datetime( - 2022, - 7, - 18, - 18, - 10, - 36, - 758000, - tzinfo=datetime.timezone.utc, - ), - ], - [ - None, - None, - ], + "foo", + datetime.datetime( + 2022, + 7, + 18, + 18, + 10, + 36, + 758000, + tzinfo=datetime.timezone.utc, + ), ], - ) - self.assertEqual(result[0][1].tzname(), "UTC") + [ + None, + None, + ], + ] + + assert result[0][1].tzname() == "UTC" + + +def test_cursor_close(mocked_connection): + """ + Verify that a cursor is not closed if not specifically closed. + """ + + cursor = mocked_connection.cursor() + cursor.execute("") + assert cursor._closed is False + + cursor.close() + + assert cursor._closed is True + assert not cursor._result + + +def test_cursor_closes_access(mocked_connection): + """ + Verify that a cursor cannot be used once it is closed. + """ + + cursor = mocked_connection.cursor() + cursor.execute("") + + cursor.close() - conn.close() + with pytest.raises(ProgrammingError): + cursor.execute("s") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..dc7f05b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from unittest.mock import MagicMock + +import pytest + +import crate + + +@pytest.fixture +def mocked_connection(): + """ + Returns a crate connection with a mocked client + + Example: + def test_conn(mocked_connection): + cursor = mocked_connection.cursor() + cursor.execute("select 1") + """ + yield crate.client.connect(client=MagicMock(spec=crate.client.http.Client)) From c8dfa3a0fc86a4b441ca4cf8c19224256f1362d3 Mon Sep 17 00:00:00 2001 From: surister Date: Tue, 21 Oct 2025 13:50:45 +0200 Subject: [PATCH 05/23] Migrate several http tests --- tests/client/test_http.py | 347 +++++++++++++++++--------------------- 1 file changed, 158 insertions(+), 189 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index c4c0609e..3c282292 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -40,6 +40,7 @@ from urllib.parse import parse_qs, urlparse import certifi +import pytest import urllib3.exceptions from crate.client.exceptions import ( @@ -57,6 +58,8 @@ REQUEST = "crate.client.http.Server.request" CA_CERT_PATH = certifi.where() +mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) + def fake_request(response=None): def request(*args, **kwargs): @@ -67,7 +70,7 @@ def request(*args, **kwargs): elif response: return response else: - return MagicMock(spec=urllib3.response.HTTPResponse) + return mocked_request return request @@ -80,28 +83,12 @@ def fake_response(status, reason=None, content_type="application/json"): return m -def fake_redirect(location): +def fake_redirect(location: str) -> MagicMock: m = fake_response(307) m.get_redirect_location.return_value = location return m -def bad_bulk_response(): - r = fake_response(400, "Bad Request") - r.data = json.dumps( - { - "results": [ - {"rowcount": 1}, - {"error_message": "an error occured"}, - {"error_message": "another error"}, - {"error_message": ""}, - {"error_message": None}, - ] - } - ).encode() - return r - - def duplicate_key_exception(): r = fake_response(409, "Conflict") r.data = json.dumps( @@ -109,7 +96,7 @@ def duplicate_key_exception(): "error": { "code": 4091, "message": "DuplicateKeyException[A document with the " - "same primary key exists already]", + "same primary key exists already]", } } ).encode() @@ -117,200 +104,183 @@ def duplicate_key_exception(): def fail_sometimes(*args, **kwargs): + # random.randint(1, 10) % 2: if random.randint(1, 100) % 10 == 0: raise urllib3.exceptions.MaxRetryError(None, "/_sql", "") return fake_response(200) -class HttpClientTest(TestCase): - @patch( - REQUEST, - fake_request( - [ - fake_response(200), - fake_response(104, "Connection reset by peer"), - fake_response(503, "Service Unavailable"), - ] - ), - ) - def test_connection_reset_exception(self): +def test_connection_reset_exception(): + """ + Verify that a HTTP 503 status code response raises an exception. + """ + + expected_exception_msg = ("No more Servers available," + " exception from last server: Service Unavailable") + with patch(REQUEST, side_effect=[ + fake_response(200), + fake_response(104, "Connection reset by peer"), + fake_response(503, "Service Unavailable"), + ]): client = Client(servers="localhost:4200") - client.sql("select 1") - client.sql("select 2") - self.assertEqual( - ["http://localhost:4200"], list(client._active_servers) - ) - try: - client.sql("select 3") - except ProgrammingError: - self.assertEqual([], list(client._active_servers)) - else: - self.assertTrue(False) - finally: - client.close() + client.sql("select 1") # 200 response + client.sql("select 2") # 104 response + assert list(client._active_servers) == ["http://localhost:4200"] - def test_no_connection_exception(self): - client = Client(servers="localhost:9999") - self.assertRaises(ConnectionError, client.sql, "select foo") - client.close() + with pytest.raises(ProgrammingError, match=expected_exception_msg): + client.sql("select 3") # 503 response + assert not client._active_servers - @patch(REQUEST) - def test_http_error_is_re_raised(self, request): - request.side_effect = Exception - client = Client() - self.assertRaises(ProgrammingError, client.sql, "select foo") - client.close() +def test_no_connection_exception(): + """ + Verify that when no connection can be made to the server, a `ConnectionError` is raised. + """ + client = Client(servers="localhost:9999") + with pytest.raises(ConnectionError): + client.sql("") + + +def test_http_error_is_re_raised(): + """ + Verify that when calling `REQUEST` if any error occurs, a `ProgrammingError` exception + is raised _from_ that exception. + """ + client = Client() + + with patch(REQUEST, side_effect=Exception): + client.sql("select foo") + with pytest.raises(ProgrammingError) as e: + client.sql("select foo") - @patch(REQUEST) - def test_programming_error_contains_http_error_response_content( - self, request - ): - request.side_effect = Exception("this shouldn't be raised") +def test_programming_error_contains_http_error_response_content(): + """ + Verify that when calling `REQUEST` if any error occurs, + the raised `ProgrammingError` exception + contains the error message from the original error. + """ + expected_msg = "this message should appear" + with patch(REQUEST, side_effect=Exception(expected_msg)): client = Client() - try: + with pytest.raises(ProgrammingError, match=expected_msg): client.sql("select 1") - except ProgrammingError as e: - self.assertEqual("this shouldn't be raised", e.message) - else: - self.assertTrue(False) - finally: - client.close() - - @patch( - REQUEST, - fake_request( - [fake_response(200), fake_response(503, "Service Unavailable")] - ), - ) - def test_server_error_50x(self): - client = Client(servers="localhost:4200 localhost:4201") - client.sql("select 1") - client.sql("select 2") - try: - client.sql("select 3") - except ProgrammingError as e: - self.assertEqual( - "No more Servers available, " - + "exception from last server: Service Unavailable", - e.message, - ) - self.assertEqual([], list(client._active_servers)) - else: - self.assertTrue(False) - finally: - client.close() - def test_connect(self): - client = Client(servers="localhost:4200 localhost:4201") - self.assertEqual( - client._active_servers, - ["http://localhost:4200", "http://localhost:4201"], - ) - client.close() - client = Client(servers="localhost:4200") - self.assertEqual(client._active_servers, ["http://localhost:4200"]) - client.close() +def test_connect(): + """ + Verify the correctness `server` parameter in `Client` instantiation. + """ + client = Client(servers="localhost:4200 localhost:4201") + assert client._active_servers == \ + ["http://localhost:4200", "http://localhost:4201"] - client = Client(servers=["localhost:4200"]) - self.assertEqual(client._active_servers, ["http://localhost:4200"]) - client.close() + # By default, it's http://127.0.0.1:4200 + client = Client(servers=None) + assert client._active_servers == ["http://127.0.0.1:4200"] - client = Client(servers=["localhost:4200", "127.0.0.1:4201"]) - self.assertEqual( - client._active_servers, - ["http://localhost:4200", "http://127.0.0.1:4201"], - ) - client.close() + with pytest.raises(TypeError, match='expected string or bytes'): + Client(servers=[123, "127.0.0.1:4201", False]) - @patch(REQUEST, fake_request(fake_redirect("http://localhost:4201"))) - def test_redirect_handling(self): + +def test_redirect_handling(): + """ + Verify that when a redirect happens, that redirect uri gets added to the server pool. + """ + with patch(REQUEST, return_value=fake_redirect("http://localhost:4201")): client = Client(servers="localhost:4200") - try: - client.blob_get("blobs", "fake_digest") - except ProgrammingError: + + # Don't try to print the exception or use `match`, otherwise + # the recursion will not be short-circuited and it will hang. + with pytest.raises(ProgrammingError): # 4201 gets added to serverpool but isn't available # that's why we run into an infinite recursion # exception message is: maximum recursion depth exceeded - pass - self.assertEqual( - ["http://localhost:4200", "http://localhost:4201"], - sorted(client.server_pool.keys()), - ) - # the new non-https server must not contain any SSL only arguments - # regression test for github issue #179/#180 - self.assertEqual( - {"socket_options": _get_socket_opts(keepalive=True)}, - client.server_pool["http://localhost:4201"].pool.conn_kw, - ) - client.close() + client.blob_get("blobs", "fake_digest") - @patch(REQUEST) - def test_server_infos(self, request): - request.side_effect = urllib3.exceptions.MaxRetryError( - None, "/", "this shouldn't be raised" - ) + assert sorted(client.server_pool.keys()) == ["http://localhost:4200", + "http://localhost:4201"] + + # the new non-https server must not contain any SSL only arguments + # regression test for: + # - https://github.com/crate/crate-python/issues/179 + # - https://github.com/crate/crate-python/issues/180 + + assert client.server_pool["http://localhost:4201"].pool.conn_kw == { + "socket_options": _get_socket_opts(keepalive=True) + } + + +def test_server_infos(): + """ + Verify that when a `MaxRetryError` is raised, a `ConnectionError` is raised. + """ + error = urllib3.exceptions.MaxRetryError(None, "/") + with patch(REQUEST, side_effect=error): client = Client(servers="localhost:4200 localhost:4201") - self.assertRaises( - ConnectionError, client.server_infos, "http://localhost:4200" - ) - client.close() + with pytest.raises(ConnectionError): + client.server_infos("http://localhost:4200") - @patch(REQUEST, fake_request(fake_response(503))) - def test_server_infos_503(self): - client = Client(servers="localhost:4200") - self.assertRaises( - ConnectionError, client.server_infos, "http://localhost:4200" - ) - client.close() - @patch( - REQUEST, fake_request(fake_response(401, "Unauthorized", "text/html")) - ) - def test_server_infos_401(self): +def test_server_infos_401(): + """ + Verify that when a 401 status code is returned, a `ProgrammingError` is raised. + """ + response = fake_response(401, "Unauthorized", "text/html") + with patch(REQUEST, return_value=response): client = Client(servers="localhost:4200") - try: + with pytest.raises(ProgrammingError, match="401 Client Error: Unauthorized"): client.server_infos("http://localhost:4200") - except ProgrammingError as e: - self.assertEqual("401 Client Error: Unauthorized", e.message) - else: - self.assertTrue(False, msg="Exception should have been raised") - finally: - client.close() - @patch(REQUEST, fake_request(bad_bulk_response())) - def test_bad_bulk_400(self): - client = Client(servers="localhost:4200") - try: + +def test_bad_bulk_400(): + """ + Verify that a 400 response when doing a bulk request raises a `ProgrammingException` with + the error message of the response object's key `error_message`, several error messages can + be returned by the database. + """ + response = fake_response(400, "Bad Request") + response.data = json.dumps( + { + "results": [ + {"rowcount": 1}, + {"error_message": "an error occurred"}, + {"error_message": "another error"}, + {"error_message": ""}, + {"error_message": None}, + ] + } + ).encode() + + client = Client(servers="localhost:4200") + with patch(REQUEST, return_value=response): + with pytest.raises(ProgrammingError, match='an error occurred\nanother error'): client.sql( "Insert into users (name) values(?)", - bulk_parameters=[["douglas"], ["monthy"]], + bulk_parameters=[["douglas"], ["monthy"]] ) - except ProgrammingError as e: - self.assertEqual("an error occured\nanother error", e.message) - else: - self.assertTrue(False, msg="Exception should have been raised") - finally: - client.close() - @patch(REQUEST, autospec=True) - def test_decimal_serialization(self, request): + +def test_decimal_serialization(): + """ + Verify that a `Decimal` type can be serialized and sent to the server. + """ + with patch(REQUEST, return_value=fake_response(200)) as request: client = Client(servers="localhost:4200") - request.return_value = fake_response(200) dec = Decimal(0.12) client.sql("insert into users (float_col) values (?)", (dec,)) - data = json.loads(request.call_args[1]["data"]) - self.assertEqual(data["args"], [str(dec)]) - client.close() + assert dec == Decimal(data["args"][0]) - @patch(REQUEST, autospec=True) - def test_datetime_is_converted_to_ts(self, request): + + +def test_datetime_is_converted_to_ts(): + """ + Verify that a `datetime.datetime` can be serialized. + """ + with patch(REQUEST, return_value=fake_response(200)) as request: client = Client(servers="localhost:4200") - request.return_value = fake_response(200) datetime = dt.datetime(2015, 2, 28, 7, 31, 40) client.sql("insert into users (dt) values (?)", (datetime,)) @@ -318,30 +288,29 @@ def test_datetime_is_converted_to_ts(self, request): # convert string to dict # because the order of the keys isn't deterministic data = json.loads(request.call_args[1]["data"]) - self.assertEqual(data["args"], [1425108700000]) - client.close() + assert data["args"][0] == 1425108700000 - @patch(REQUEST, autospec=True) - def test_date_is_converted_to_ts(self, request): + +def test_date_is_converted_to_ts(): + """ + Verify that a `datetime.date` can be serialized. + """ + with patch(REQUEST, return_value=fake_response(200)) as request: client = Client(servers="localhost:4200") - request.return_value = fake_response(200) day = dt.date(2016, 4, 21) client.sql("insert into users (dt) values (?)", (day,)) data = json.loads(request.call_args[1]["data"]) - self.assertEqual(data["args"], [1461196800000]) - client.close() + assert data["args"][0] == 1461196800000 - def test_socket_options_contain_keepalive(self): - server = "http://localhost:4200" - client = Client(servers=server) - conn_kw = client.server_pool[server].pool.conn_kw - self.assertIn( - (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), - conn_kw["socket_options"], - ) - client.close() +def test_socket_options_contain_keepalive(): + client = Client(servers="http://localhost:4200") + conn_kw = client.server_pool[server].pool.conn_kw + assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in conn_kw["socket_options"] + + +class HttpClientTest(TestCase): @patch(REQUEST, autospec=True) def test_uuid_serialization(self, request): client = Client(servers="localhost:4200") From d854bbfd6c490fd28ff76e0128a22d7926de65ab Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:53:25 +0200 Subject: [PATCH 06/23] Rename `REQUEST` to `REQUEST_PATH` and move it to conftest.py with `fake_response` --- tests/client/test_http.py | 26 +++++++++----------------- tests/conftest.py | 24 ++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 3c282292..0092ae4f 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -54,8 +54,8 @@ _remove_certs_for_non_https, json_dumps, ) +from tests.conftest import REQUEST_PATH, fake_response -REQUEST = "crate.client.http.Server.request" CA_CERT_PATH = certifi.where() mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) @@ -75,14 +75,6 @@ def request(*args, **kwargs): return request -def fake_response(status, reason=None, content_type="application/json"): - m = MagicMock(spec=urllib3.response.HTTPResponse) - m.status = status - m.reason = reason or "" - m.headers = {"content-type": content_type} - return m - - def fake_redirect(location: str) -> MagicMock: m = fake_response(307) m.get_redirect_location.return_value = location @@ -117,7 +109,7 @@ def test_connection_reset_exception(): expected_exception_msg = ("No more Servers available," " exception from last server: Service Unavailable") - with patch(REQUEST, side_effect=[ + with patch(REQUEST_PATH, side_effect=[ fake_response(200), fake_response(104, "Connection reset by peer"), fake_response(503, "Service Unavailable"), @@ -148,7 +140,7 @@ def test_http_error_is_re_raised(): """ client = Client() - with patch(REQUEST, side_effect=Exception): + with patch(REQUEST_PATH, side_effect=Exception): client.sql("select foo") with pytest.raises(ProgrammingError) as e: client.sql("select foo") @@ -161,7 +153,7 @@ def test_programming_error_contains_http_error_response_content(): contains the error message from the original error. """ expected_msg = "this message should appear" - with patch(REQUEST, side_effect=Exception(expected_msg)): + with patch(REQUEST_PATH, side_effect=Exception(expected_msg)): client = Client() with pytest.raises(ProgrammingError, match=expected_msg): client.sql("select 1") @@ -169,7 +161,7 @@ def test_programming_error_contains_http_error_response_content(): def test_connect(): """ - Verify the correctness `server` parameter in `Client` instantiation. + Verify the correctness of `server` parameter when `Client` is instantiated. """ client = Client(servers="localhost:4200 localhost:4201") assert client._active_servers == \ @@ -187,7 +179,7 @@ def test_redirect_handling(): """ Verify that when a redirect happens, that redirect uri gets added to the server pool. """ - with patch(REQUEST, return_value=fake_redirect("http://localhost:4201")): + with patch(REQUEST_PATH, return_value=fake_redirect("http://localhost:4201")): client = Client(servers="localhost:4200") # Don't try to print the exception or use `match`, otherwise @@ -216,7 +208,7 @@ def test_server_infos(): Verify that when a `MaxRetryError` is raised, a `ConnectionError` is raised. """ error = urllib3.exceptions.MaxRetryError(None, "/") - with patch(REQUEST, side_effect=error): + with patch(REQUEST_PATH, side_effect=error): client = Client(servers="localhost:4200 localhost:4201") with pytest.raises(ConnectionError): client.server_infos("http://localhost:4200") @@ -227,7 +219,7 @@ def test_server_infos_401(): Verify that when a 401 status code is returned, a `ProgrammingError` is raised. """ response = fake_response(401, "Unauthorized", "text/html") - with patch(REQUEST, return_value=response): + with patch(REQUEST_PATH, return_value=response): client = Client(servers="localhost:4200") with pytest.raises(ProgrammingError, match="401 Client Error: Unauthorized"): client.server_infos("http://localhost:4200") @@ -253,7 +245,7 @@ def test_bad_bulk_400(): ).encode() client = Client(servers="localhost:4200") - with patch(REQUEST, return_value=response): + with patch(REQUEST_PATH, return_value=response): with pytest.raises(ProgrammingError, match='an error occurred\nanother error'): client.sql( "Insert into users (name) values(?)", diff --git a/tests/conftest.py b/tests/conftest.py index dc7f05b3..c5e2bd0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,38 @@ +import urllib3 from unittest.mock import MagicMock import pytest import crate +REQUEST_PATH = "crate.client.http.Server.request" + + +def fake_response( + status: int, + reason: str = None, + content_type: str = "application/json" +) -> MagicMock: + """ + Returns a mocked `urllib3.response.HTTPResponse` HTTP response. + """ + m = MagicMock(spec=urllib3.response.HTTPResponse) + m.status = status + m.reason = reason or "" + m.headers = {"content-type": content_type} + return m + @pytest.fixture def mocked_connection(): """ - Returns a crate connection with a mocked client + Returns a crate `Connection` with a mocked `Client` Example: def test_conn(mocked_connection): cursor = mocked_connection.cursor() - cursor.execute("select 1") + statement = "select * from locations where position = ?" + cursor.execute(statement, 1) + mocked_connection.client.sql.assert_called_once_with(statement, 1, None) """ yield crate.client.connect(client=MagicMock(spec=crate.client.http.Client)) From 6c0c8b6bd32563a197346107a8c4a22b1c148428 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:56:52 +0200 Subject: [PATCH 07/23] Migrate more tests --- tests/client/test_http.py | 235 ++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 134 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 0092ae4f..801b150d 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -60,7 +60,6 @@ mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) - def fake_request(response=None): def request(*args, **kwargs): if isinstance(response, list): @@ -253,76 +252,26 @@ def test_bad_bulk_400(): ) -def test_decimal_serialization(): - """ - Verify that a `Decimal` type can be serialized and sent to the server. - """ - with patch(REQUEST, return_value=fake_response(200)) as request: - client = Client(servers="localhost:4200") - - dec = Decimal(0.12) - client.sql("insert into users (float_col) values (?)", (dec,)) - data = json.loads(request.call_args[1]["data"]) - assert dec == Decimal(data["args"][0]) - - - -def test_datetime_is_converted_to_ts(): - """ - Verify that a `datetime.datetime` can be serialized. - """ - with patch(REQUEST, return_value=fake_response(200)) as request: - client = Client(servers="localhost:4200") - - datetime = dt.datetime(2015, 2, 28, 7, 31, 40) - client.sql("insert into users (dt) values (?)", (datetime,)) - - # convert string to dict - # because the order of the keys isn't deterministic - data = json.loads(request.call_args[1]["data"]) - assert data["args"][0] == 1425108700000 - - -def test_date_is_converted_to_ts(): +def test_socket_options_contain_keepalive(): """ - Verify that a `datetime.date` can be serialized. + Verify that KEEPALIVE options are present at `socket_options` """ - with patch(REQUEST, return_value=fake_response(200)) as request: - client = Client(servers="localhost:4200") - - day = dt.date(2016, 4, 21) - client.sql("insert into users (dt) values (?)", (day,)) - data = json.loads(request.call_args[1]["data"]) - assert data["args"][0] == 1461196800000 - - -def test_socket_options_contain_keepalive(): - client = Client(servers="http://localhost:4200") + server = "http://localhost:4200" + client = Client(servers=server) conn_kw = client.server_pool[server].pool.conn_kw assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in conn_kw["socket_options"] -class HttpClientTest(TestCase): - @patch(REQUEST, autospec=True) - def test_uuid_serialization(self, request): - client = Client(servers="localhost:4200") - request.return_value = fake_response(200) - - uid = uuid.uuid4() - client.sql("insert into my_table (str_col) values (?)", (uid,)) - - data = json.loads(request.call_args[1]["data"]) - self.assertEqual(data["args"], [str(uid)]) - client.close() - - @patch(REQUEST, fake_request(duplicate_key_exception())) - def test_duplicate_key_error(self): - """ - Verify that an `IntegrityError` is raised on duplicate key errors, - instead of the more general `ProgrammingError`. - """ +def test_duplicate_key_error(): + """ + Verify that an `IntegrityError` is raised on duplicate key errors, + instead of the more general `ProgrammingError`. + """ + expected_error_msg = (r"DuplicateKeyException\[A document with " + r"the same primary key exists already\]") + with patch(REQUEST_PATH, fake_request(duplicate_key_exception())): client = Client(servers="localhost:4200") - with self.assertRaises(IntegrityError) as cm: + with pytest.raises(IntegrityError, match=expected_error_msg): client.sql("INSERT INTO testdrive (foo) VALUES (42)") self.assertEqual( cm.exception.message, @@ -331,16 +280,21 @@ def test_duplicate_key_error(self): ) -@patch(REQUEST, fail_sometimes) -class ThreadSafeHttpClientTest(TestCase): +@patch(REQUEST_PATH, fail_sometimes) +def test_client_multithreaded(): """ - Using a pool of 5 Threads to emit commands to the multiple servers through - one Client-instance + Verify client multithreading using a pool of 5 Threads to emit commands to + the multiple servers through one Client-instance. - check if number of servers in _inactive_servers and _active_servers always + Checks if the number of servers in _inactive_servers and _active_servers always equals the number of servers initially given. - """ + Note: + This test is probabilistic and does not ensure that the + client is indeed thread-safe in all cases, it can only show that it + withstands this scenario. + + """ servers = [ "127.0.0.1:44209", "127.0.0.2:44209", @@ -350,67 +304,94 @@ class ThreadSafeHttpClientTest(TestCase): num_commands = 1000 thread_timeout = 5.0 # seconds - def __init__(self, *args, **kwargs): - self.event = Event() - self.err_queue = queue.Queue() - super(ThreadSafeHttpClientTest, self).__init__(*args, **kwargs) + gate = Event() + error_queue = queue.Queue() def setUp(self): self.client = Client(self.servers) self.client.retry_interval = 0.2 # faster retry + client = Client(servers) + client.retry_interval = 0.2 # faster retry - def tearDown(self): - self.client.close() - - def _run(self): - self.event.wait() # wait for the others - expected_num_servers = len(self.servers) - for _ in range(self.num_commands): + def worker(): + """ + Worker that sends many requests, if the `num_server` is not expected at some point + an assertion will be added to the shared error queue. + """ + gate.wait() # wait for the others + expected_num_servers = len(servers) + for _ in range(num_commands): try: - self.client.sql("select name from sys.cluster") + client.sql("select name from sys.cluster") except ConnectionError: + # Sometimes it will fail. pass try: - with self.client._lock: - num_servers = len(self.client._active_servers) + len( - self.client._inactive_servers + with client._lock: + num_servers = len(client._active_servers) + len( + client._inactive_servers ) - self.assertEqual( - expected_num_servers, - num_servers, - "expected %d but got %d" - % (expected_num_servers, num_servers), - ) - except AssertionError: - self.err_queue.put(sys.exc_info()) - - def test_client_threaded(self): - """ - Testing if lists of servers is handled correctly when client is used - from multiple threads with some requests failing. + assert num_servers == expected_num_servers, ( + f"expected {expected_num_servers} but got {num_servers}" + ) + except AssertionError as e: + error_queue.put(e) - **ATTENTION:** this test is probabilistic and does not ensure that the - client is indeed thread-safe in all cases, it can only show that it - withstands this scenario. - """ - threads = [ - Thread(target=self._run, name=str(x)) - for x in range(self.num_threads) - ] - for thread in threads: - thread.start() - - self.event.set() - for t in threads: - t.join(self.thread_timeout) - - if not self.err_queue.empty(): - self.assertTrue( - False, - "".join( - traceback.format_exception(*self.err_queue.get(block=False)) - ), - ) + threads = [ + Thread(target=worker, name=str(i)) + for i in range(num_threads) + ] + + for thread in threads: + thread.start() + + gate.set() + + for t in threads: + t.join(timeout=thread_timeout) + + # If any thread is still alive after the timeout, consider it a failure. + alive = [t.name for t in threads if t.is_alive()] + if alive: + pytest.fail(f"Threads did not finish within {thread_timeout}s: {alive}") + + if not error_queue.empty(): + # If an error happened, consider it a failure as well. + first_error_trace = error_queue.get(block=False) + pytest.fail(first_error_trace) + + +def test_params(): + """ + Verify client parameters translate correctly to query parameters.. + """ + client = Client(["127.0.0.1:4200"], error_trace=True) + parsed = urlparse(client.path) + params = parse_qs(parsed.query) + + assert params["error_trace"] == ["true"] + assert params["types"] == ["true"] + + client = Client(["127.0.0.1:4200"]) + parsed = urlparse(client.path) + params = parse_qs(parsed.query) + + # Default is FALSE + assert 'error_trace' not in params + assert params["types"] == ["true"] + + assert "/_sql?" in client.path + + +def test_client_ca(): + os.environ["REQUESTS_CA_BUNDLE"] = CA_CERT_PATH + try: + Client("http://127.0.0.1:4200") + except ProgrammingError: + pytest.fail("HTTP not working with REQUESTS_CA_BUNDLE") + finally: + os.unsetenv("REQUESTS_CA_BUNDLE") + os.environ["REQUESTS_CA_BUNDLE"] = "" class ClientAddressRequestHandler(BaseHTTPRequestHandler): @@ -474,20 +455,6 @@ def test_client_keepalive(self): self.assertEqual(result, another_result) -class ParamsTest(TestCase): - def test_params(self): - client = Client(["127.0.0.1:4200"], error_trace=True) - parsed = urlparse(client.path) - params = parse_qs(parsed.query) - self.assertEqual(params["error_trace"], ["true"]) - client.close() - - def test_no_params(self): - client = Client() - self.assertEqual(client.path, "/_sql?types=true") - client.close() - - class RequestsCaBundleTest(TestCase): def test_open_client(self): os.environ["REQUESTS_CA_BUNDLE"] = CA_CERT_PATH From aa7794f6c3a75e39696b6ab34b0a6d2f59e6fc22 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:57:06 +0200 Subject: [PATCH 08/23] fix typo --- tests/client/test_cursor.py | 4 +--- tests/client/test_http.py | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index b8054128..d9fd6c87 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -35,11 +35,9 @@ from crate.client import connect from crate.client.converter import DataType, DefaultTypeConverter -from crate.client.http import Client -from crate.testing.util import ClientMocked -def test_create_with_timezome_as_datetime_object(mocked_connection): +def test_create_with_timezone_as_datetime_object(mocked_connection): """ The cursor can return timezone-aware `datetime` objects when requested. Switching the time zone at runtime on the cursor object is possible. diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 801b150d..0e8ebb68 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -307,9 +307,6 @@ def test_client_multithreaded(): gate = Event() error_queue = queue.Queue() - def setUp(self): - self.client = Client(self.servers) - self.client.retry_interval = 0.2 # faster retry client = Client(servers) client.retry_interval = 0.2 # faster retry From 8f46388811167835ed90c41ae0b13d3793558390 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:57:16 +0200 Subject: [PATCH 09/23] Clean imports --- tests/client/test_http.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 0e8ebb68..12ede8a3 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -19,19 +19,17 @@ # with Crate these terms will supersede the license and you may use the # software solely pursuant to the terms of the relevant commercial agreement. -import datetime as dt import json import multiprocessing import os import queue import random import socket -import sys + import time -import traceback -import uuid + from base64 import b64decode -from decimal import Decimal + from http.server import BaseHTTPRequestHandler, HTTPServer from multiprocessing.context import ForkProcess from threading import Event, Thread @@ -52,7 +50,6 @@ Client, _get_socket_opts, _remove_certs_for_non_https, - json_dumps, ) from tests.conftest import REQUEST_PATH, fake_response @@ -273,11 +270,6 @@ def test_duplicate_key_error(): client = Client(servers="localhost:4200") with pytest.raises(IntegrityError, match=expected_error_msg): client.sql("INSERT INTO testdrive (foo) VALUES (42)") - self.assertEqual( - cm.exception.message, - "DuplicateKeyException[A document with the " - "same primary key exists already]", - ) @patch(REQUEST_PATH, fail_sometimes) @@ -644,15 +636,3 @@ def test_username(self): ) self.assertEqual(TestingHTTPServer.SHARED["username"], "testDBUser") self.assertEqual(TestingHTTPServer.SHARED["password"], "test:password") - - -class TestCrateJsonEncoder(TestCase): - def test_naive_datetime(self): - data = dt.datetime.fromisoformat("2023-06-26T09:24:00.123") - result = json_dumps(data) - self.assertEqual(result, b"1687771440123") - - def test_aware_datetime(self): - data = dt.datetime.fromisoformat("2023-06-26T09:24:00.123+02:00") - result = json_dumps(data) - self.assertEqual(result, b"1687764240123") From 3bc6cff9efbbf6d86b3e066ade64a3af75938e64 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:57:36 +0200 Subject: [PATCH 10/23] Add specific tests for serialization --- tests/client/test_serialization.py | 118 +++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/client/test_serialization.py diff --git a/tests/client/test_serialization.py b/tests/client/test_serialization.py new file mode 100644 index 00000000..5f922a00 --- /dev/null +++ b/tests/client/test_serialization.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8; -*- +# +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +""" +Tests for serializing data, typically python objects into CrateDB-sql compatible structures. +""" +import datetime + +import uuid +from decimal import Decimal +from unittest.mock import patch, MagicMock +import datetime as dt + +from crate.client.http import json_dumps, Client +from tests.conftest import REQUEST_PATH, fake_response + + +def test_data_is_serialized(): + """ + Verify that when a request is issued, `json_dumps` is called with the right parameters + and that a requests gets the output from json_dumps, this verifies the entire + serialization call chain, so in the following tests we can just test `json_dumps` and ignore + `Client` altogether. + """ + mock = MagicMock(spec=bytes) + + with patch('crate.client.http.json_dumps', return_value=mock) as json_dumps: + with patch(REQUEST_PATH, return_value=fake_response(200)) as request: + client = Client(servers="localhost:4200") + client.sql( + "insert into t (a, b) values (?, ?)", + (datetime.datetime(2025, 10, 23, 11, ), + "ss" + ) + ) + + # Verify json_dumps is called with the right parameters. + json_dumps.assert_called_once_with( + { + 'stmt': 'insert into t (a, b) values (?, ?)', + 'args': (datetime.datetime(2025, 10, 23, 11, 0), 'ss') + } + ) + + # Verify that the output of json_dumps is used as call argument for a request. + assert request.call_args[1]['data'] is mock + + +def test_naive_datetime_serialization(): + """ + Verify that a `datetime.datetime` can be serialized. + """ + data = dt.datetime(2015, 2, 28, 7, 31, 40) + result = json_dumps(data) + assert isinstance(result, bytes) + assert result == b'1425108700000' + + +def test_aware_datetime_serialization(): + """ + Verify that a `datetime` that is tz aware type can be serialized. + """ + data = dt.datetime.fromisoformat("2023-06-26T09:24:00.123+02:00") + result = json_dumps(data) + assert isinstance(result, bytes) + assert result == b"1687764240123" + + +def test_decimal_serialization(): + """ + Verify that a `Decimal` type can be serialized. + """ + + data = Decimal(0.12) + result = json_dumps(data) + assert isinstance(result, bytes) + + # Question: Is this deterministic in every Python release? + assert result == b'"0.11999999999999999555910790149937383830547332763671875"' + + +def test_date_serialization(): + """ + Verify that a `datetime.date` can be serialized. + """ + data = dt.date(2016, 4, 21) + result = json_dumps(data) + assert result == b'1461196800000' + + + +def test_uuid_serialization(): + """ + Verify that a `uuid.UUID` can be serialized. We do not care about specific uuid versions, + just the object that is re-used across all versions by the uuid module. + """ + data = uuid.UUID(bytes=(50583033507982468033520929066863110751).to_bytes(16), version=4) + result = json_dumps(data) + assert result == b'"260df019-a183-431f-ad46-115ccdf12a5f"' + From 8cc192dde1849b72cbb7dffca82096e89f457912 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 14:58:04 +0200 Subject: [PATCH 11/23] Add `temp_env` util function to temporarily set env variables with a context manager --- tests/client/test_utils.py | 24 ++++++++++++++++++++++++ tests/client/utils.py | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/client/test_utils.py create mode 100644 tests/client/utils.py diff --git a/tests/client/test_utils.py b/tests/client/test_utils.py new file mode 100644 index 00000000..1606ed5a --- /dev/null +++ b/tests/client/test_utils.py @@ -0,0 +1,24 @@ +import os + +from tests.client.utils import temp_env + + +def test_util_temp_env(): + """ + Verify that temp_env correctly injects and cleans up environment variables. + """ + env = os.environ.copy() + new_env_var = {'some_var': '1'} + + with temp_env(**new_env_var): + # Assert that the difference of the two environs is the new variable. + assert set(env.items()) ^ set(os.environ.items()) == set(new_env_var.items()) + + # key should now not exist in the current imported os.environ. + assert list(new_env_var.keys())[0] not in os.environ + + import importlib + importlib.reload(os) + + # key should still not exist even when reloading the os module. + assert list(new_env_var.keys())[0] not in os.environ \ No newline at end of file diff --git a/tests/client/utils.py b/tests/client/utils.py new file mode 100644 index 00000000..7f1bdf0e --- /dev/null +++ b/tests/client/utils.py @@ -0,0 +1,25 @@ +import contextlib +import os + + +@contextlib.contextmanager +def temp_env(**environment_variables): + """ + Context manager that sets temporal environment variables within the scope. + + Example: + with temp_env(some_key='value'): + import os + 'some_key' in os.environ + # True + 'some_key' in os.environ + # False + """ + try: + for k, v in environment_variables.items(): + os.environ[k] = str(v) + yield + finally: + for k, v in environment_variables.items(): + os.unsetenv(k) + del os.environ[k] \ No newline at end of file From 4a2fbf89788b83637479598cdf379834a0407c9e Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 17:04:19 +0200 Subject: [PATCH 12/23] Migrate client ca tests --- tests/client/test_http.py | 54 ++++++++++++------------------ tests/client/test_serialization.py | 6 ++-- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 12ede8a3..745095cf 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -51,12 +51,12 @@ _get_socket_opts, _remove_certs_for_non_https, ) +from tests.client.utils import temp_env from tests.conftest import REQUEST_PATH, fake_response -CA_CERT_PATH = certifi.where() - mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) + def fake_request(response=None): def request(*args, **kwargs): if isinstance(response, list): @@ -373,14 +373,26 @@ def test_params(): def test_client_ca(): - os.environ["REQUESTS_CA_BUNDLE"] = CA_CERT_PATH - try: - Client("http://127.0.0.1:4200") - except ProgrammingError: - pytest.fail("HTTP not working with REQUESTS_CA_BUNDLE") - finally: - os.unsetenv("REQUESTS_CA_BUNDLE") - os.environ["REQUESTS_CA_BUNDLE"] = "" + """ + Verify that if env variable `REQUESTS_CA_BUNDLE` is set, certs are loaded into the pool. + """ + with temp_env(REQUESTS_CA_BUNDLE=certifi.where()): + client = Client("http://127.0.0.1:4200") + assert 'ca_certs' in client._pool_kw + + +def test_remove_certs_for_non_https(): + """ + Verify that `_remove_certs_for_non_https` correctly removes ca_certs. + """ + d = _remove_certs_for_non_https("https", {"ca_certs": 1}) + assert "ca_certs" in d + + kwargs = {"ca_certs": 1, "foobar": 2, "cert_file": 3} + d = _remove_certs_for_non_https("http", kwargs) + assert 'ca_certs' not in d + assert 'cert_file' not in d + assert 'foobar' in d class ClientAddressRequestHandler(BaseHTTPRequestHandler): @@ -444,28 +456,6 @@ def test_client_keepalive(self): self.assertEqual(result, another_result) -class RequestsCaBundleTest(TestCase): - def test_open_client(self): - os.environ["REQUESTS_CA_BUNDLE"] = CA_CERT_PATH - try: - Client("http://127.0.0.1:4200") - except ProgrammingError: - self.fail("HTTP not working with REQUESTS_CA_BUNDLE") - finally: - os.unsetenv("REQUESTS_CA_BUNDLE") - os.environ["REQUESTS_CA_BUNDLE"] = "" - - def test_remove_certs_for_non_https(self): - d = _remove_certs_for_non_https("https", {"ca_certs": 1}) - self.assertIn("ca_certs", d) - - kwargs = {"ca_certs": 1, "foobar": 2, "cert_file": 3} - d = _remove_certs_for_non_https("http", kwargs) - self.assertNotIn("ca_certs", d) - self.assertNotIn("cert_file", d) - self.assertIn("foobar", d) - - class TimeoutRequestHandler(BaseHTTPRequestHandler): """ HTTP handler for use with TestingHTTPServer diff --git a/tests/client/test_serialization.py b/tests/client/test_serialization.py index 5f922a00..982b3915 100644 --- a/tests/client/test_serialization.py +++ b/tests/client/test_serialization.py @@ -76,7 +76,7 @@ def test_naive_datetime_serialization(): def test_aware_datetime_serialization(): """ - Verify that a `datetime` that is tz aware type can be serialized. + Verify that a `datetime` that is tz aware type can be serialized. """ data = dt.datetime.fromisoformat("2023-06-26T09:24:00.123+02:00") result = json_dumps(data) @@ -106,13 +106,11 @@ def test_date_serialization(): assert result == b'1461196800000' - def test_uuid_serialization(): """ Verify that a `uuid.UUID` can be serialized. We do not care about specific uuid versions, - just the object that is re-used across all versions by the uuid module. + just the object that is re-used across all versions of the uuid module. """ data = uuid.UUID(bytes=(50583033507982468033520929066863110751).to_bytes(16), version=4) result = json_dumps(data) assert result == b'"260df019-a183-431f-ad46-115ccdf12a5f"' - From b8a79fce0d86b83606c8966d68fffe6b7cb3b171 Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 17:52:11 +0200 Subject: [PATCH 13/23] Migrate more tests --- tests/client/test_connection.py | 67 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py index 0cc5e1ef..d5b6ed54 100644 --- a/tests/client/test_connection.py +++ b/tests/client/test_connection.py @@ -1,5 +1,6 @@ import datetime from unittest import TestCase +from unittest.mock import patch, MagicMock from urllib3 import Timeout @@ -10,6 +11,47 @@ from .settings import crate_host +def test_lowest_server_version(): + """ + Verify the lowest server version is correctly set. + """ + infos = [ + (None, None, "1.0.3"), + (None, None, "5.5.2"), + (None, None, "6.0.0"), + (None, None, "not a version"), + ] + + client = Client(servers="localhost:4200 localhost:4201 localhost:4202 localhost:4207") + client.server_infos = lambda server: infos.pop() + connection = connect(client=client) + assert (1, 0, 3) == connection.lowest_server_version.version + + +def test_invalid_server_version(): + """ + Verify that when no correct version is set, the default (0, 0, 0) is returned. + """ + client = Client(servers="localhost:4200") + client.server_infos = lambda server: (None, None, "No version") + connection = connect(client=client) + assert (0, 0, 0) == connection.lowest_server_version.version + + +def test_context_manager(): + """ + Verify the context manager implementation of `Connection`. + """ + with patch('crate.client.http.Client.close', return_value=MagicMock()) as close_func: + with connect("localhost:4200") as conn: + assert conn._closed == False + + assert conn._closed == True + # Checks that the close method of the client + # is called when the connection is closed. + close_func.assert_called_once() + + class ConnectionTest(TestCase): def test_connection_mock(self): """ @@ -36,31 +78,6 @@ def server_infos(self, server): ("localhost:4200", "my server", "0.42.0"), ) - def test_lowest_server_version(self): - infos = [ - (None, None, "0.42.3"), - (None, None, "0.41.8"), - (None, None, "not a version"), - ] - - client = Client(servers="localhost:4200 localhost:4201 localhost:4202") - client.server_infos = lambda server: infos.pop() - connection = connect(client=client) - self.assertEqual((0, 41, 8), connection.lowest_server_version.version) - connection.close() - - def test_invalid_server_version(self): - client = Client(servers="localhost:4200") - client.server_infos = lambda server: (None, None, "No version") - connection = connect(client=client) - self.assertEqual((0, 0, 0), connection.lowest_server_version.version) - connection.close() - - def test_context_manager(self): - with connect("localhost:4200") as conn: - pass - self.assertEqual(conn._closed, True) - def test_with_timezone(self): """ The cursor can return timezone-aware `datetime` objects when requested. From bf2903a4c40adbb4728801787efed99ee39e4a6c Mon Sep 17 00:00:00 2001 From: surister Date: Thu, 23 Oct 2025 18:02:46 +0200 Subject: [PATCH 14/23] Migrate test_connection --- tests/client/test_connection.py | 139 ++++++++++++++++---------------- 1 file changed, 69 insertions(+), 70 deletions(-) diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py index d5b6ed54..084202dd 100644 --- a/tests/client/test_connection.py +++ b/tests/client/test_connection.py @@ -52,73 +52,72 @@ def test_context_manager(): close_func.assert_called_once() -class ConnectionTest(TestCase): - def test_connection_mock(self): - """ - For testing purposes it is often useful to replace the client used for - communication with the CrateDB server with a stub or mock. - - This can be done by passing an object of the Client class when calling - the `connect` method. - """ - - class MyConnectionClient: - active_servers = ["localhost:4200"] - - def __init__(self): - pass - - def server_infos(self, server): - return ("localhost:4200", "my server", "0.42.0") - - connection = connect([crate_host], client=MyConnectionClient()) - self.assertIsInstance(connection, Connection) - self.assertEqual( - connection.client.server_infos("foo"), - ("localhost:4200", "my server", "0.42.0"), - ) - - def test_with_timezone(self): - """ - The cursor can return timezone-aware `datetime` objects when requested. - - When switching the time zone at runtime on the connection object, only - new cursor objects will inherit the new time zone. - """ - - tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") - connection = connect("localhost:4200", time_zone=tz_mst) - cursor = connection.cursor() - self.assertEqual(cursor.time_zone.tzname(None), "MST") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=25200) - ) - - connection.time_zone = datetime.timezone.utc - cursor = connection.cursor() - self.assertEqual(cursor.time_zone.tzname(None), "UTC") - self.assertEqual( - cursor.time_zone.utcoffset(None), datetime.timedelta(0) - ) - - def test_timeout_float(self): - """ - Verify setting the timeout value as a scalar (float) works. - """ - with connect("localhost:4200", timeout=2.42) as conn: - self.assertEqual(conn.client._pool_kw["timeout"], 2.42) - - def test_timeout_string(self): - """ - Verify setting the timeout value as a scalar (string) works. - """ - with connect("localhost:4200", timeout="2.42") as conn: - self.assertEqual(conn.client._pool_kw["timeout"], 2.42) - - def test_timeout_object(self): - """ - Verify setting the timeout value as a Timeout object works. - """ - timeout = Timeout(connect=2.42, read=0.01) - with connect("localhost:4200", timeout=timeout) as conn: - self.assertEqual(conn.client._pool_kw["timeout"], timeout) +def test_connection_mock(): + """ + Verify that a custom client can be passed. + + + For testing purposes, it is often useful to replace the client used for + communication with the CrateDB server with a stub or mock. + + This can be done by passing an object of the Client class when calling + the `connect` method. + """ + + mock = MagicMock(spec=Client) + mock.server_infos.return_value = "localhost:4200", "my server", "0.42.0" + connection = connect(crate_host, client=mock) + + assert isinstance(connection, Connection) + assert connection.client.server_infos("foo") == ("localhost:4200", "my server", "0.42.0") + + +def test_with_timezone(): + """ + Verify the logic of passing timezone objects to the client. + + The cursor can return timezone-aware `datetime` objects when requested. + + When switching the time zone at runtime on the connection object, only + new cursor objects will inherit the new time zone. + + These tests are complementary to timezone `test_cursor` + """ + + tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST") + connection = connect("localhost:4200", time_zone=tz_mst) + cursor = connection.cursor() + + assert cursor.time_zone.tzname(None) == "MST" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=25200) + + connection.time_zone = datetime.timezone.utc + cursor = connection.cursor() + assert cursor.time_zone.tzname(None) == "UTC" + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(0) + + + +def test_timeout_float(): + """ + Verify setting the timeout value as a scalar (float) works. + """ + with connect("localhost:4200", timeout=2.42) as conn: + assert conn.client._pool_kw["timeout"] == 2.42 + + +def test_timeout_string(): + """ + Verify setting the timeout value as a scalar (string) works. + """ + with connect("localhost:4200", timeout="2.42") as conn: + assert conn.client._pool_kw["timeout"] == 2.42 + + +def test_timeout_object(): + """ + Verify setting the timeout value as a Timeout object works. + """ + timeout = Timeout(connect=2.42, read=0.01) + with connect("localhost:4200", timeout=timeout) as conn: + assert conn.client._pool_kw["timeout"] == timeout From 6db454dcb670b12099ae54e3887a2d3110028640 Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 10:27:47 +0200 Subject: [PATCH 15/23] refactor fail_sometimes --- tests/client/test_http.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 745095cf..493b45cb 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -21,7 +21,7 @@ import json import multiprocessing -import os + import queue import random import socket @@ -91,9 +91,12 @@ def duplicate_key_exception(): return r -def fail_sometimes(*args, **kwargs): - # random.randint(1, 10) % 2: - if random.randint(1, 100) % 10 == 0: +def fail_sometimes(*args, **kwargs) -> MagicMock: + """ + Function that fails with a 50% chance. It either returns a successful mocked + response or raises an urllib3 exception. + """ + if random.randint(1, 10) % 2: raise urllib3.exceptions.MaxRetryError(None, "/_sql", "") return fake_response(200) From 010fcfa1d50f6ecf4a425676c36e5f66c18f4cde Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 10:34:15 +0200 Subject: [PATCH 16/23] Remove temp_env util function --- tests/client/test_http.py | 5 +++-- tests/client/test_utils.py | 24 ------------------------ tests/client/utils.py | 25 ------------------------- 3 files changed, 3 insertions(+), 51 deletions(-) delete mode 100644 tests/client/test_utils.py delete mode 100644 tests/client/utils.py diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 493b45cb..82824004 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -21,6 +21,7 @@ import json import multiprocessing +import os import queue import random @@ -51,7 +52,7 @@ _get_socket_opts, _remove_certs_for_non_https, ) -from tests.client.utils import temp_env + from tests.conftest import REQUEST_PATH, fake_response mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) @@ -379,7 +380,7 @@ def test_client_ca(): """ Verify that if env variable `REQUESTS_CA_BUNDLE` is set, certs are loaded into the pool. """ - with temp_env(REQUESTS_CA_BUNDLE=certifi.where()): + with patch.dict(os.environ, dict(REQUEST_PATH=certifi.where()), clear=True): client = Client("http://127.0.0.1:4200") assert 'ca_certs' in client._pool_kw diff --git a/tests/client/test_utils.py b/tests/client/test_utils.py deleted file mode 100644 index 1606ed5a..00000000 --- a/tests/client/test_utils.py +++ /dev/null @@ -1,24 +0,0 @@ -import os - -from tests.client.utils import temp_env - - -def test_util_temp_env(): - """ - Verify that temp_env correctly injects and cleans up environment variables. - """ - env = os.environ.copy() - new_env_var = {'some_var': '1'} - - with temp_env(**new_env_var): - # Assert that the difference of the two environs is the new variable. - assert set(env.items()) ^ set(os.environ.items()) == set(new_env_var.items()) - - # key should now not exist in the current imported os.environ. - assert list(new_env_var.keys())[0] not in os.environ - - import importlib - importlib.reload(os) - - # key should still not exist even when reloading the os module. - assert list(new_env_var.keys())[0] not in os.environ \ No newline at end of file diff --git a/tests/client/utils.py b/tests/client/utils.py deleted file mode 100644 index 7f1bdf0e..00000000 --- a/tests/client/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import contextlib -import os - - -@contextlib.contextmanager -def temp_env(**environment_variables): - """ - Context manager that sets temporal environment variables within the scope. - - Example: - with temp_env(some_key='value'): - import os - 'some_key' in os.environ - # True - 'some_key' in os.environ - # False - """ - try: - for k, v in environment_variables.items(): - os.environ[k] = str(v) - yield - finally: - for k, v in environment_variables.items(): - os.unsetenv(k) - del os.environ[k] \ No newline at end of file From 08e9401783723b90072182beda24109de3c9270c Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 13:32:39 +0200 Subject: [PATCH 17/23] Migrate test_http and fix some test_cursor --- src/crate/client/http.py | 5 +- tests/client/test_cursor.py | 2 +- tests/client/test_http.py | 318 +++++++++++++----------------------- tests/conftest.py | 60 +++++++ 4 files changed, 180 insertions(+), 205 deletions(-) diff --git a/src/crate/client/http.py b/src/crate/client/http.py index a1251d34..b1d51f02 100644 --- a/src/crate/client/http.py +++ b/src/crate/client/http.py @@ -326,7 +326,10 @@ def _pool_kw_args( return kw -def _remove_certs_for_non_https(server, kwargs): +def _remove_certs_for_non_https(server: str, kwargs: dict) -> dict: + """ + Removes certificates for http requests. + """ if server.lower().startswith("https"): return kwargs used_ssl_args = SSL_ONLY_ARGS & set(kwargs.keys()) diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index d9fd6c87..c8353b92 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -21,7 +21,7 @@ import datetime from ipaddress import IPv4Address -from unittest import TestCase, mock +from unittest import mock import pytest from crate.client.exceptions import ProgrammingError diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 82824004..ec8822de 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -20,7 +20,7 @@ # software solely pursuant to the terms of the relevant commercial agreement. import json -import multiprocessing + import os import queue @@ -31,10 +31,10 @@ from base64 import b64decode -from http.server import BaseHTTPRequestHandler, HTTPServer -from multiprocessing.context import ForkProcess +from http.server import BaseHTTPRequestHandler + from threading import Event, Thread -from unittest import TestCase + from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -42,6 +42,7 @@ import pytest import urllib3.exceptions +from crate.client.connection import connect from crate.client.exceptions import ( ConnectionError, IntegrityError, @@ -58,20 +59,6 @@ mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) -def fake_request(response=None): - def request(*args, **kwargs): - if isinstance(response, list): - resp = response.pop(0) - response.append(resp) - return resp - elif response: - return response - else: - return mocked_request - - return request - - def fake_redirect(location: str) -> MagicMock: m = fake_response(307) m.get_redirect_location.return_value = location @@ -92,7 +79,7 @@ def duplicate_key_exception(): return r -def fail_sometimes(*args, **kwargs) -> MagicMock: +def fail_sometimes() -> MagicMock: """ Function that fails with a 50% chance. It either returns a successful mocked response or raises an urllib3 exception. @@ -140,9 +127,9 @@ def test_http_error_is_re_raised(): """ client = Client() - with patch(REQUEST_PATH, side_effect=Exception): - client.sql("select foo") - with pytest.raises(ProgrammingError) as e: + exception_msg = 'some exception did happen' + with patch(REQUEST_PATH, side_effect=Exception(exception_msg)): + with pytest.raises(ProgrammingError, match=exception_msg): client.sql("select foo") @@ -270,7 +257,7 @@ def test_duplicate_key_error(): """ expected_error_msg = (r"DuplicateKeyException\[A document with " r"the same primary key exists already\]") - with patch(REQUEST_PATH, fake_request(duplicate_key_exception())): + with patch(REQUEST_PATH, return_value=duplicate_key_exception()): client = Client(servers="localhost:4200") with pytest.raises(IntegrityError, match=expected_error_msg): client.sql("INSERT INTO testdrive (foo) VALUES (42)") @@ -356,7 +343,7 @@ def worker(): def test_params(): """ - Verify client parameters translate correctly to query parameters.. + Verify client parameters translate correctly to query parameters. """ client = Client(["127.0.0.1:4200"], error_trace=True) parsed = urlparse(client.path) @@ -369,7 +356,7 @@ def test_params(): parsed = urlparse(client.path) params = parse_qs(parsed.query) - # Default is FALSE + # Default is False assert 'error_trace' not in params assert params["types"] == ["true"] @@ -399,76 +386,78 @@ def test_remove_certs_for_non_https(): assert 'foobar' in d -class ClientAddressRequestHandler(BaseHTTPRequestHandler): +def test_keep_alive(serve_http): """ - http handler for use with HTTPServer + Verify that when launching several requests, the connection is kept alive and + successfully terminates. - returns client host and port in crate-conform-responses + This uses a real http sever that mocks CrateDB-like responses. """ - protocol_version = "HTTP/1.1" + class ClientAddressRequestHandler(BaseHTTPRequestHandler): + """ + http handler for use with HTTPServer - def do_GET(self): - content_length = self.headers.get("content-length") - if content_length: - self.rfile.read(int(content_length)) - response = json.dumps( - { - "cols": ["host", "port"], - "rows": [self.client_address[0], self.client_address[1]], - "rowCount": 1, - } - ) - self.send_response(200) - self.send_header("Content-Length", len(response)) - self.send_header("Content-Type", "application/json; charset=UTF-8") - self.end_headers() - self.wfile.write(response.encode("UTF-8")) + returns client host and port in crate-conform-responses + """ - do_POST = do_PUT = do_DELETE = do_HEAD = do_GET + protocol_version = "HTTP/1.1" + def do_GET(self): + content_length = self.headers.get("content-length") + if content_length: + self.rfile.read(int(content_length)) -class KeepAliveClientTest(TestCase): - server_address = ("127.0.0.1", 65535) + response = json.dumps( + { + "cols": ["host", "port"], + "rows": [self.client_address[0], self.client_address[1]], + "rowCount": 1, + } + ) - def __init__(self, *args, **kwargs): - super(KeepAliveClientTest, self).__init__(*args, **kwargs) - self.server_process = ForkProcess(target=self._run_server) + self.send_response(200) + self.send_header("Content-Length", str(len(response))) + self.send_header("Content-Type", "application/json; charset=UTF-8") + self.end_headers() + self.wfile.write(response.encode("UTF-8")) - def setUp(self): - super(KeepAliveClientTest, self).setUp() - self.client = Client(["%s:%d" % self.server_address]) - self.server_process.start() - time.sleep(0.10) + do_POST = do_GET - def tearDown(self): - self.server_process.terminate() - self.client.close() - super(KeepAliveClientTest, self).tearDown() + with serve_http(ClientAddressRequestHandler) as (_, url): + with connect(url) as conn: + client = conn.client + for _ in range(25): + result = client.sql("select * from fake") - def _run_server(self): - self.server = HTTPServer( - self.server_address, ClientAddressRequestHandler - ) - self.server.handle_request() + another_result = client.sql("select again from fake") + assert result == another_result - def test_client_keepalive(self): - for _ in range(10): - result = self.client.sql("select * from fake") - another_result = self.client.sql("select again from fake") - self.assertEqual(result, another_result) +def test_no_retry_on_read_timeout(serve_http): + timeout = 1 + class TimeoutRequestHandler(BaseHTTPRequestHandler): + """ + HTTP handler for use with TestingHTTPServer + updates the shared counter and waits so that the client times out + """ -class TimeoutRequestHandler(BaseHTTPRequestHandler): - """ - HTTP handler for use with TestingHTTPServer - updates the shared counter and waits so that the client times out - """ + def do_POST(self): + self.server.SHARED["count"] += 1 + time.sleep(timeout + 0.1) - def do_POST(self): - self.server.SHARED["count"] += 1 - time.sleep(5) + def do_GET(self): + pass + + # Start the http server. + with serve_http(TimeoutRequestHandler) as (server, url): + # Connect to the server. + with connect(url, timeout=timeout) as conn: + # We expect it to raise a `ConnectionError` + with pytest.raises(ConnectionError, match="Read timed out"): + conn.client.sql("select * from fake") + assert server.SHARED.get('count') == 1 class SharedStateRequestHandler(BaseHTTPRequestHandler): @@ -505,128 +494,51 @@ def do_POST(self): self.end_headers() self.wfile.write(response.encode("utf-8")) - -class TestingHTTPServer(HTTPServer): - """ - http server providing a shared dict - """ - - manager = multiprocessing.Manager() - SHARED = manager.dict() - SHARED["count"] = 0 - SHARED["usernameFromXUser"] = None - SHARED["username"] = None - SHARED["password"] = None - SHARED["schema"] = None - - @classmethod - def run_server(cls, server_address, request_handler_cls): - cls(server_address, request_handler_cls).serve_forever() - - -class TestingHttpServerTestCase(TestCase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.assertIsNotNone(self.request_handler) - self.server_address = ("127.0.0.1", random.randint(65000, 65535)) - self.server_process = ForkProcess( - target=TestingHTTPServer.run_server, - args=(self.server_address, self.request_handler), - ) - - def setUp(self): - self.server_process.start() - self.wait_for_server() - - def wait_for_server(self): - while True: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(self.server_address) - except Exception: - time.sleep(0.25) - else: - break - - def tearDown(self): - self.server_process.terminate() - - def clientWithKwargs(self, **kwargs): - return Client(["%s:%d" % self.server_address], timeout=1, **kwargs) - - -class RetryOnTimeoutServerTest(TestingHttpServerTestCase): - request_handler = TimeoutRequestHandler - - def setUp(self): - super().setUp() - self.client = self.clientWithKwargs() - - def tearDown(self): - super().tearDown() - self.client.close() - - def test_no_retry_on_read_timeout(self): - try: - self.client.sql("select * from fake") - except ConnectionError as e: - self.assertIn( - "Read timed out", - e.message, - msg="Error message must contain: Read timed out", - ) - self.assertEqual(TestingHTTPServer.SHARED["count"], 1) - - -class TestDefaultSchemaHeader(TestingHttpServerTestCase): - request_handler = SharedStateRequestHandler - - def setUp(self): - super().setUp() - self.client = self.clientWithKwargs(schema="my_custom_schema") - - def tearDown(self): - self.client.close() - super().tearDown() - - def test_default_schema(self): - self.client.sql("SELECT 1") - self.assertEqual(TestingHTTPServer.SHARED["schema"], "my_custom_schema") - - -class TestUsernameSentAsHeader(TestingHttpServerTestCase): - request_handler = SharedStateRequestHandler - - def setUp(self): - super().setUp() - self.clientWithoutUsername = self.clientWithKwargs() - self.clientWithUsername = self.clientWithKwargs(username="testDBUser") - self.clientWithUsernameAndPassword = self.clientWithKwargs( - username="testDBUser", password="test:password" - ) - - def tearDown(self): - self.clientWithoutUsername.close() - self.clientWithUsername.close() - self.clientWithUsernameAndPassword.close() - super().tearDown() - - def test_username(self): - self.clientWithoutUsername.sql("select * from fake") - self.assertEqual(TestingHTTPServer.SHARED["usernameFromXUser"], None) - self.assertEqual(TestingHTTPServer.SHARED["username"], None) - self.assertEqual(TestingHTTPServer.SHARED["password"], None) - - self.clientWithUsername.sql("select * from fake") - self.assertEqual( - TestingHTTPServer.SHARED["usernameFromXUser"], "testDBUser" - ) - self.assertEqual(TestingHTTPServer.SHARED["username"], "testDBUser") - self.assertEqual(TestingHTTPServer.SHARED["password"], None) - - self.clientWithUsernameAndPassword.sql("select * from fake") - self.assertEqual( - TestingHTTPServer.SHARED["usernameFromXUser"], "testDBUser" - ) - self.assertEqual(TestingHTTPServer.SHARED["username"], "testDBUser") - self.assertEqual(TestingHTTPServer.SHARED["password"], "test:password") + def do_GET(self): + pass + +def test_default_schema(serve_http): + """ + Verify that the schema is correctly sent. + """ + test_schema = 'some_schema' + with serve_http(SharedStateRequestHandler) as (server, url): + with connect(url, schema=test_schema) as conn: + conn.client.sql("select 1;") + assert server.SHARED.get("schema") == test_schema + +def test_credentials(serve_http): + """ + Verify credentials are correctly set in the connection and client. + """ + with serve_http(SharedStateRequestHandler) as (server, url): + # Nothing default + with connect(url) as conn: + assert not conn.client.username + assert not conn.client.password + + conn.client.sql("select 1;") + assert not server.SHARED["usernameFromXUser"] + assert not server.SHARED["username"] + assert not server.SHARED["password"] + + # Just the username + username = "some_username" + with connect(url, username=username) as conn: + assert conn.client.username == username + assert not conn.client.password + + conn.client.sql("select 2;") + assert server.SHARED["usernameFromXUser"] == username + assert server.SHARED["username"] == username + assert not server.SHARED["password"] + + # Both username and password + password = 'some_password' + with connect(url, username=username, password=password) as conn: + assert conn.client.username == username + assert conn.client.password == password + conn.client.sql("select 3;") + assert server.SHARED["usernameFromXUser"] == username + assert server.SHARED["username"] == username + assert server.SHARED["password"] == password diff --git a/tests/conftest.py b/tests/conftest.py index c5e2bd0b..ce3d2858 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,9 @@ +import multiprocessing +import socket +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer + import urllib3 from unittest.mock import MagicMock @@ -36,3 +42,57 @@ def test_conn(mocked_connection): mocked_connection.client.sql.assert_called_once_with(statement, 1, None) """ yield crate.client.connect(client=MagicMock(spec=crate.client.http.Client)) + + + + +@pytest.fixture +def serve_http(): + """ + Returns a context manager that start an http server running in another thread that returns + CrateDB successful responses. + + It accepts an optional parameter, the handler class, it has to be an instance of `BaseHTTPRequestHandler` + The port will be an unused random port. + + Example: + def test_http(serve_http): + with serve_http() as url: + urllib3.urlopen(url) + + See `test_http.test_keep_alive` for more advance example. + """ + + @contextmanager + def _serve(handler_cls=BaseHTTPRequestHandler): + assert issubclass(handler_cls, BaseHTTPRequestHandler) + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + host, port = sock.getsockname() + sock.close() + + manager = multiprocessing.Manager() + SHARED = manager.dict() + SHARED["count"] = 0 + SHARED["usernameFromXUser"] = None + SHARED["username"] = None + SHARED["password"] = None + SHARED["schema"] = None + + server = HTTPServer((host, port), handler_cls) + + server.SHARED = SHARED + + # We could use a process, but starting a thread is faster. + # process = ForkProcess(target=server.serve_forever) + # process.start() + + thread = threading.Thread(target=server.serve_forever, daemon=False) + thread.start() + try: + yield server, f"http://{host}:{port}" + + finally: + server.shutdown() + thread.join() + return _serve \ No newline at end of file From 1548b035892d310e751488a6b7b838e358749a0c Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 17:56:22 +0200 Subject: [PATCH 18/23] Fix linting --- src/crate/client/cursor.py | 3 +- tests/client/test_connection.py | 20 +++++---- tests/client/test_cursor.py | 48 +++++++++++++++------ tests/client/test_exceptions.py | 2 +- tests/client/test_http.py | 67 ++++++++++++++++-------------- tests/client/test_serialization.py | 37 ++++++++++------- tests/conftest.py | 21 ++++------ 7 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/crate/client/cursor.py b/src/crate/client/cursor.py index 2a82d502..2931d858 100644 --- a/src/crate/client/cursor.py +++ b/src/crate/client/cursor.py @@ -236,7 +236,8 @@ def _convert_rows(self): # Process result rows with conversion. for row in self._result["rows"]: - yield [convert(value) for convert, value in zip(converters, row)] + yield [convert(value) for convert, value + in zip(converters, row, strict=False)] @property def time_zone(self): diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py index 084202dd..8d4f897f 100644 --- a/tests/client/test_connection.py +++ b/tests/client/test_connection.py @@ -1,6 +1,5 @@ import datetime -from unittest import TestCase -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from urllib3 import Timeout @@ -15,6 +14,7 @@ def test_lowest_server_version(): """ Verify the lowest server version is correctly set. """ + servers = "localhost:4200 localhost:4201 localhost:4202 localhost:4207" infos = [ (None, None, "1.0.3"), (None, None, "5.5.2"), @@ -22,7 +22,7 @@ def test_lowest_server_version(): (None, None, "not a version"), ] - client = Client(servers="localhost:4200 localhost:4201 localhost:4202 localhost:4207") + client = Client(servers=servers) client.server_infos = lambda server: infos.pop() connection = connect(client=client) assert (1, 0, 3) == connection.lowest_server_version.version @@ -30,7 +30,8 @@ def test_lowest_server_version(): def test_invalid_server_version(): """ - Verify that when no correct version is set, the default (0, 0, 0) is returned. + Verify that when no correct version is set, + the default (0, 0, 0) is returned. """ client = Client(servers="localhost:4200") client.server_infos = lambda server: (None, None, "No version") @@ -42,11 +43,12 @@ def test_context_manager(): """ Verify the context manager implementation of `Connection`. """ - with patch('crate.client.http.Client.close', return_value=MagicMock()) as close_func: + close_method = 'crate.client.http.Client.close' + with patch(close_method, return_value=MagicMock()) as close_func: with connect("localhost:4200") as conn: - assert conn._closed == False + assert not conn._closed - assert conn._closed == True + assert conn._closed # Checks that the close method of the client # is called when the connection is closed. close_func.assert_called_once() @@ -69,7 +71,9 @@ def test_connection_mock(): connection = connect(crate_host, client=mock) assert isinstance(connection, Connection) - assert connection.client.server_infos("foo") == ("localhost:4200", "my server", "0.42.0") + assert connection.client.server_infos("foo") == ("localhost:4200", + "my server", + "0.42.0") def test_with_timezone(): diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index c8353b92..3425e40a 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -22,6 +22,7 @@ import datetime from ipaddress import IPv4Address from unittest import mock + import pytest from crate.client.exceptions import ProgrammingError @@ -61,7 +62,8 @@ def test_create_with_timezone_as_pytz_object(mocked_connection): Here: Use a `pytz.timezone` instance. """ - cursor = mocked_connection.cursor(time_zone=pytz.timezone("Australia/Sydney")) + cursor = (mocked_connection + .cursor(time_zone=pytz.timezone("Australia/Sydney"))) assert cursor.time_zone.tzname(None) == "Australia/Sydney" # Apparently, when using `pytz`, the timezone object does not return @@ -83,7 +85,9 @@ def test_create_with_timezone_as_zoneinfo_object(mocked_connection): def test_create_with_timezone_as_utc_offset_success(mocked_connection): """ - Verify the cursor can return timezone-aware `datetime` objects when requested. + Verify the cursor can return timezone-aware `datetime` objects when + requested. + Here: Use a UTC offset in string format. """ @@ -93,7 +97,8 @@ def test_create_with_timezone_as_utc_offset_success(mocked_connection): cursor = mocked_connection.cursor(time_zone="-1145") assert cursor.time_zone.tzname(None) == "-1145" - assert cursor.time_zone.utcoffset(None) == datetime.timedelta(days=-1, seconds=44100) + assert cursor.time_zone.utcoffset(None) == datetime.timedelta(days=-1, + seconds=44100) def test_create_with_timezone_as_utc_offset_failure(mocked_connection): @@ -172,7 +177,10 @@ def test_execute_custom_converter(mocked_connection): "duration": 123, } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object( + mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") result = cursor.fetchall() @@ -211,7 +219,11 @@ def test_execute_with_converter_and_invalid_data_type(mocked_connection): "rowcount": 1, "duration": 123, } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object( + mocked_connection.client, + 'sql', + return_value=response + ): cursor.execute("") with pytest.raises(ValueError) as e: cursor.fetchone() @@ -228,7 +240,10 @@ def test_execute_array_with_converter(mocked_connection): "rowcount": 1, "duration": 123, } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object( + mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") result = cursor.fetchone() @@ -238,7 +253,7 @@ def test_execute_array_with_converter(mocked_connection): ] -def test_execute_array_with_converter_and_invalid_collection_type(mocked_connection): +def test_execute_array_with_converter_invalid(mocked_connection): converter = DefaultTypeConverter() cursor = mocked_connection.cursor(converter=converter) response = { @@ -250,11 +265,14 @@ def test_execute_array_with_converter_and_invalid_collection_type(mocked_connect } # Converting collections only works for `ARRAY`s. (ID=100). # When using `DOUBLE` (ID=6), it should raise an Exception. - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") with pytest.raises(ValueError) as e: cursor.fetchone() - assert e.exception.args == "Data type 6 is not implemented as collection type" + assert e.exception.args == ("Data type 6 is not implemented" + " as collection type") def test_execute_nested_array_with_converter(mocked_connection): @@ -278,7 +296,9 @@ def test_execute_nested_array_with_converter(mocked_connection): "duration": 123, } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") result = cursor.fetchone() assert result == [ @@ -302,7 +322,9 @@ def test_executemany_with_converter(mocked_connection): "rowcount": 1, "duration": 123, } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): cursor.executemany("", []) result = cursor.fetchall() @@ -325,7 +347,9 @@ def test_execute_with_timezone(mocked_connection): [None, None], ], } - with mock.patch.object(mocked_connection.client, 'sql', return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): # Run execution and verify the returned `datetime` object is # timezone-aware, using the designated timezone object. cursor.execute("") diff --git a/tests/client/test_exceptions.py b/tests/client/test_exceptions.py index 8b385774..e5aa6817 100644 --- a/tests/client/test_exceptions.py +++ b/tests/client/test_exceptions.py @@ -13,4 +13,4 @@ def test_error_with_error_trace(): def test_blob_exception(): err = BlobException(table="sometable", digest="somedigest") - assert str(err) == "BlobException('sometable/somedigest)'" \ No newline at end of file + assert str(err) == "BlobException('sometable/somedigest)'" diff --git a/tests/client/test_http.py b/tests/client/test_http.py index ec8822de..fb4af879 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -20,21 +20,14 @@ # software solely pursuant to the terms of the relevant commercial agreement. import json - import os - import queue import random import socket - import time - from base64 import b64decode - from http.server import BaseHTTPRequestHandler - from threading import Event, Thread - from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -53,7 +46,6 @@ _get_socket_opts, _remove_certs_for_non_https, ) - from tests.conftest import REQUEST_PATH, fake_response mocked_request = MagicMock(spec=urllib3.response.HTTPResponse) @@ -79,7 +71,7 @@ def duplicate_key_exception(): return r -def fail_sometimes() -> MagicMock: +def fail_sometimes(*args, **kwargs) -> MagicMock: """ Function that fails with a 50% chance. It either returns a successful mocked response or raises an urllib3 exception. @@ -94,8 +86,8 @@ def test_connection_reset_exception(): Verify that a HTTP 503 status code response raises an exception. """ - expected_exception_msg = ("No more Servers available," - " exception from last server: Service Unavailable") + expected_exception_msg = ("No more Servers available, exception" + " from last server: Service Unavailable") with patch(REQUEST_PATH, side_effect=[ fake_response(200), fake_response(104, "Connection reset by peer"), @@ -113,7 +105,8 @@ def test_connection_reset_exception(): def test_no_connection_exception(): """ - Verify that when no connection can be made to the server, a `ConnectionError` is raised. + Verify that when no connection can be made to the server, + a `ConnectionError` is raised. """ client = Client(servers="localhost:9999") with pytest.raises(ConnectionError): @@ -122,8 +115,8 @@ def test_no_connection_exception(): def test_http_error_is_re_raised(): """ - Verify that when calling `REQUEST` if any error occurs, a `ProgrammingError` exception - is raised _from_ that exception. + Verify that when calling `REQUEST` if any error occurs, + a `ProgrammingError` exception is raised _from_ that exception. """ client = Client() @@ -164,9 +157,11 @@ def test_connect(): def test_redirect_handling(): """ - Verify that when a redirect happens, that redirect uri gets added to the server pool. + Verify that when a redirect happens, that redirect uri + gets added to the server pool. """ - with patch(REQUEST_PATH, return_value=fake_redirect("http://localhost:4201")): + with patch(REQUEST_PATH, + return_value=fake_redirect("http://localhost:4201")): client = Client(servers="localhost:4200") # Don't try to print the exception or use `match`, otherwise @@ -203,20 +198,22 @@ def test_server_infos(): def test_server_infos_401(): """ - Verify that when a 401 status code is returned, a `ProgrammingError` is raised. + Verify that when a 401 status code is returned, a `ProgrammingError` + is raised. """ response = fake_response(401, "Unauthorized", "text/html") with patch(REQUEST_PATH, return_value=response): client = Client(servers="localhost:4200") - with pytest.raises(ProgrammingError, match="401 Client Error: Unauthorized"): + with pytest.raises(ProgrammingError, + match="401 Client Error: Unauthorized"): client.server_infos("http://localhost:4200") def test_bad_bulk_400(): """ - Verify that a 400 response when doing a bulk request raises a `ProgrammingException` with - the error message of the response object's key `error_message`, several error messages can - be returned by the database. + Verify that a 400 response when doing a bulk request raises + a `ProgrammingException` with the error message of the response object's + key `error_message`, several error messages can be returned by the database. """ response = fake_response(400, "Bad Request") response.data = json.dumps( @@ -233,7 +230,8 @@ def test_bad_bulk_400(): client = Client(servers="localhost:4200") with patch(REQUEST_PATH, return_value=response): - with pytest.raises(ProgrammingError, match='an error occurred\nanother error'): + with pytest.raises(ProgrammingError, + match='an error occurred\nanother error'): client.sql( "Insert into users (name) values(?)", bulk_parameters=[["douglas"], ["monthy"]] @@ -247,7 +245,8 @@ def test_socket_options_contain_keepalive(): server = "http://localhost:4200" client = Client(servers=server) conn_kw = client.server_pool[server].pool.conn_kw - assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in conn_kw["socket_options"] + assert ((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + in conn_kw["socket_options"]) def test_duplicate_key_error(): @@ -269,8 +268,8 @@ def test_client_multithreaded(): Verify client multithreading using a pool of 5 Threads to emit commands to the multiple servers through one Client-instance. - Checks if the number of servers in _inactive_servers and _active_servers always - equals the number of servers initially given. + Checks if the number of servers in _inactive_servers and _active_servers + always equals the number of servers initially given. Note: This test is probabilistic and does not ensure that the @@ -295,8 +294,9 @@ def test_client_multithreaded(): def worker(): """ - Worker that sends many requests, if the `num_server` is not expected at some point - an assertion will be added to the shared error queue. + Worker that sends many requests, if the `num_server` is not the + expected value at some point, an assertion will be added to the shared + error queue. """ gate.wait() # wait for the others expected_num_servers = len(servers) @@ -365,9 +365,12 @@ def test_params(): def test_client_ca(): """ - Verify that if env variable `REQUESTS_CA_BUNDLE` is set, certs are loaded into the pool. + Verify that if env variable `REQUESTS_CA_BUNDLE` is set, certs are + loaded into the pool. """ - with patch.dict(os.environ, dict(REQUEST_PATH=certifi.where()), clear=True): + with patch.dict(os.environ, + {"REQUEST_PATH": certifi.where()}, + clear=True): client = Client("http://127.0.0.1:4200") assert 'ca_certs' in client._pool_kw @@ -388,8 +391,8 @@ def test_remove_certs_for_non_https(): def test_keep_alive(serve_http): """ - Verify that when launching several requests, the connection is kept alive and - successfully terminates. + Verify that when launching several requests, the connection is kept + alive and successfully terminates. This uses a real http sever that mocks CrateDB-like responses. """ @@ -497,6 +500,7 @@ def do_POST(self): def do_GET(self): pass + def test_default_schema(serve_http): """ Verify that the schema is correctly sent. @@ -507,6 +511,7 @@ def test_default_schema(serve_http): conn.client.sql("select 1;") assert server.SHARED.get("schema") == test_schema + def test_credentials(serve_http): """ Verify credentials are correctly set in the connection and client. diff --git a/tests/client/test_serialization.py b/tests/client/test_serialization.py index 982b3915..c5a4a474 100644 --- a/tests/client/test_serialization.py +++ b/tests/client/test_serialization.py @@ -20,29 +20,30 @@ # software solely pursuant to the terms of the relevant commercial agreement. """ -Tests for serializing data, typically python objects into CrateDB-sql compatible structures. +Tests for serializing data, typically python objects + into CrateDB-sql compatible structures. """ import datetime - +import datetime as dt import uuid from decimal import Decimal -from unittest.mock import patch, MagicMock -import datetime as dt +from unittest.mock import MagicMock, patch -from crate.client.http import json_dumps, Client +from crate.client.http import Client, json_dumps from tests.conftest import REQUEST_PATH, fake_response def test_data_is_serialized(): """ - Verify that when a request is issued, `json_dumps` is called with the right parameters - and that a requests gets the output from json_dumps, this verifies the entire - serialization call chain, so in the following tests we can just test `json_dumps` and ignore + Verify that when a request is issued, `json_dumps` is called with + the right parameters and that a requests gets the output from json_dumps, + this verifies the entire serialization call chain, so in the following + tests we can just test `json_dumps` and ignore `Client` altogether. """ mock = MagicMock(spec=bytes) - with patch('crate.client.http.json_dumps', return_value=mock) as json_dumps: + with patch('crate.client.http.json_dumps', return_value=mock) as f: with patch(REQUEST_PATH, return_value=fake_response(200)) as request: client = Client(servers="localhost:4200") client.sql( @@ -53,14 +54,15 @@ def test_data_is_serialized(): ) # Verify json_dumps is called with the right parameters. - json_dumps.assert_called_once_with( + f.assert_called_once_with( { 'stmt': 'insert into t (a, b) values (?, ?)', 'args': (datetime.datetime(2025, 10, 23, 11, 0), 'ss') } ) - # Verify that the output of json_dumps is used as call argument for a request. + # Verify that the output of json_dumps is used as + # call argument for a request. assert request.call_args[1]['data'] is mock @@ -90,11 +92,12 @@ def test_decimal_serialization(): """ data = Decimal(0.12) + expected = b'"0.11999999999999999555910790149937383830547332763671875"' result = json_dumps(data) assert isinstance(result, bytes) # Question: Is this deterministic in every Python release? - assert result == b'"0.11999999999999999555910790149937383830547332763671875"' + assert result == expected def test_date_serialization(): @@ -108,9 +111,13 @@ def test_date_serialization(): def test_uuid_serialization(): """ - Verify that a `uuid.UUID` can be serialized. We do not care about specific uuid versions, - just the object that is re-used across all versions of the uuid module. + Verify that a `uuid.UUID` can be serialized. + + We do not care about specific uuid versions, just the object that is + re-used across all versions of the uuid module. """ - data = uuid.UUID(bytes=(50583033507982468033520929066863110751).to_bytes(16), version=4) + data = uuid.UUID( + bytes=(50583033507982468033520929066863110751).to_bytes(16), + version=4) result = json_dumps(data) assert result == b'"260df019-a183-431f-ad46-115ccdf12a5f"' diff --git a/tests/conftest.py b/tests/conftest.py index ce3d2858..33e8b94d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,10 @@ import threading from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, HTTPServer - -import urllib3 from unittest.mock import MagicMock import pytest +import urllib3 import crate @@ -39,7 +38,7 @@ def test_conn(mocked_connection): cursor = mocked_connection.cursor() statement = "select * from locations where position = ?" cursor.execute(statement, 1) - mocked_connection.client.sql.assert_called_once_with(statement, 1, None) + mocked_connection.client.sql.called_with(statement, 1, None) """ yield crate.client.connect(client=MagicMock(spec=crate.client.http.Client)) @@ -49,10 +48,12 @@ def test_conn(mocked_connection): @pytest.fixture def serve_http(): """ - Returns a context manager that start an http server running in another thread that returns - CrateDB successful responses. + Returns a context manager that start an http server running + in another thread that returns CrateDB successful responses. + + It accepts an optional parameter, the handler class, it has to be an + instance of `BaseHTTPRequestHandler` - It accepts an optional parameter, the handler class, it has to be an instance of `BaseHTTPRequestHandler` The port will be an unused random port. Example: @@ -65,7 +66,7 @@ def test_http(serve_http): @contextmanager def _serve(handler_cls=BaseHTTPRequestHandler): - assert issubclass(handler_cls, BaseHTTPRequestHandler) + assert issubclass(handler_cls, BaseHTTPRequestHandler) # noqa: S101 sock = socket.socket() sock.bind(("127.0.0.1", 0)) host, port = sock.getsockname() @@ -83,10 +84,6 @@ def _serve(handler_cls=BaseHTTPRequestHandler): server.SHARED = SHARED - # We could use a process, but starting a thread is faster. - # process = ForkProcess(target=server.serve_forever) - # process.start() - thread = threading.Thread(target=server.serve_forever, daemon=False) thread.start() try: @@ -95,4 +92,4 @@ def _serve(handler_cls=BaseHTTPRequestHandler): finally: server.shutdown() thread.join() - return _serve \ No newline at end of file + return _serve From 1cc2986b42cd6f74a916bcb07b8df6d3e3c5e588 Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 17:57:24 +0200 Subject: [PATCH 19/23] Fix CI --- .github/workflows/tests.yml | 26 +- pyproject.toml | 85 ++++--- uv.lock | 492 ++++++++++++++++++++++++++++++++++++ 3 files changed, 550 insertions(+), 53 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 629463ac..72f0eb44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,32 +49,32 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@v6 with: - cache-dependency-glob: | - setup.py cache-suffix: ${{ matrix.python-version }} enable-cache: true version: "latest" - + - name: Setup env + run: uv sync - name: Invoke tests run: | - + # Propagate build matrix information. ./devtools/setup_ci.sh # Bootstrap environment. - source bootstrap.sh - + # source bootstrap.sh + + # Run linter. + uv run ruff check . + + # Run type testing + uv run mypy + # Report about the test matrix slot. echo "Invoking tests with CrateDB ${CRATEDB_VERSION}" - - # Run linter. - poe lint + uv run coverage run -m pytest - # Run tests. - coverage run bin/test -vvv - # Set the stage for uploading the coverage report. - coverage xml + uv run coverage xml # https://github.com/codecov/codecov-action - name: Upload coverage results to Codecov diff --git a/pyproject.toml b/pyproject.toml index 1a496054..19a92729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,11 @@ dependencies = [ [dependency-groups] dev = [ "certifi>=2025.10.5", + "coverage>=7.11.0", + "mypy>=1.18.2", "pytest>=8.4.2", "pytz>=2025.2", + "ruff>=0.14.2", "setuptools>=80.9.0", "stopit>=1.1.2", "verlib2>=0.3.1", @@ -67,65 +70,67 @@ non_interactive = true line-length = 80 extend-exclude = [ - "/example_*", + "/example_*", ] lint.select = [ - # Builtins - "A", - # Bugbear - "B", - # comprehensions - "C4", - # Pycodestyle - "E", - # eradicate - "ERA", - # Pyflakes - "F", - # isort - "I", - # pandas-vet - "PD", - # return - "RET", - # Bandit - "S", - # print - "T20", - "W", - # flake8-2020 - "YTT", + # Builtins + "A", + # Bugbear + "B", + # comprehensions + "C4", + # Pycodestyle + "E", + # eradicate + "ERA", + # Pyflakes + "F", + # isort + "I", + # pandas-vet + "PD", + # return + "RET", + # Bandit + "S", + # print + "T20", + "W", + # flake8-2020 + "YTT", ] lint.extend-ignore = [ - # Unnecessary variable assignment before `return` statement - "RET504", - # Unnecessary `elif` after `return` statement - "RET505", + # Unnecessary variable assignment before `return` statement + "RET504", + # Unnecessary `elif` after `return` statement + "RET505", ] lint.per-file-ignores."example_*" = [ - "ERA001", # Found commented-out code - "T201", # Allow `print` + "ERA001", # Found commented-out code + "T201", # Allow `print` ] lint.per-file-ignores."devtools/*" = [ - "T201", # Allow `print` + "T201", # Allow `print` ] lint.per-file-ignores."examples/*" = [ - "ERA001", # Found commented-out code - "T201", # Allow `print` + "ERA001", # Found commented-out code + "T201", # Allow `print` ] lint.per-file-ignores."tests/*" = [ - "S106", # Possible hardcoded password assigned to argument: "password" - "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "S101", # Asserts. + "S105", # Possible hardcoded password assigned to: "password" + "S106", # Possible hardcoded password assigned to argument: "password" + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes ] lint.per-file-ignores."src/crate/client/{connection.py,http.py}" = [ - "A004", # Import `ConnectionError` is shadowing a Python builtin - "A005", # Import `ConnectionError` is shadowing a Python builtin + "A004", # Import `ConnectionError` is shadowing a Python builtin + "A005", # Import `ConnectionError` is shadowing a Python builtin ] lint.per-file-ignores."tests/client/test_http.py" = [ - "A004", # Import `ConnectionError` is shadowing a Python builtin + "A004", # Import `ConnectionError` is shadowing a Python builtin ] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..25092ae1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,492 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/95/c49df0aceb5507a80b9fe5172d3d39bf23f05be40c23c8d77d556df96cec/coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31", size = 215800, upload-time = "2025-10-15T15:12:19.824Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c6/7bb46ce01ed634fff1d7bb53a54049f539971862cc388b304ff3c51b4f66/coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075", size = 216198, upload-time = "2025-10-15T15:12:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/75d9d8fbf2900268aca5de29cd0a0fe671b0f69ef88be16767cc3c828b85/coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab", size = 242953, upload-time = "2025-10-15T15:12:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/65/ac/acaa984c18f440170525a8743eb4b6c960ace2dbad80dc22056a437fc3c6/coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0", size = 244766, upload-time = "2025-10-15T15:12:25.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/938d0bff76dfa4a6b228c3fc4b3e1c0e2ad4aa6200c141fcda2bd1170227/coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785", size = 246625, upload-time = "2025-10-15T15:12:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/38/54/8f5f5e84bfa268df98f46b2cb396b1009734cfb1e5d6adb663d284893b32/coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591", size = 243568, upload-time = "2025-10-15T15:12:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/8ba337c2877fe3f2e1af0ed7ff4be0c0c4aca44d6f4007040f3ca2255e99/coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088", size = 244665, upload-time = "2025-10-15T15:12:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/c6f1d6d9a665536b7dde2333346f0cc41dc6a60bd1ffc10cd5c33e7eb000/coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f", size = 242681, upload-time = "2025-10-15T15:12:32.326Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/1b532319af5f991fa153c20373291dc65c2bf532af7dbcffdeef745c8f79/coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866", size = 242912, upload-time = "2025-10-15T15:12:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/f39331c60ef6050d2a861dc1b514fa78f85f792820b68e8c04196ad733d6/coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841", size = 243559, upload-time = "2025-10-15T15:12:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/4b/55/cb7c9df9d0495036ce582a8a2958d50c23cd73f84a23284bc23bd4711a6f/coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf", size = 218266, upload-time = "2025-10-15T15:12:37.429Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/b79cb275fa7bd0208767f89d57a1b5f6ba830813875738599741b97c2e04/coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969", size = 219169, upload-time = "2025-10-15T15:12:39.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" }, + { url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" }, + { url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" }, + { url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" }, + { url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + +[[package]] +name = "crate-python" +source = { editable = "." } +dependencies = [ + { name = "orjson" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "certifi" }, + { name = "coverage" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytz" }, + { name = "ruff" }, + { name = "setuptools" }, + { name = "stopit" }, + { name = "verlib2" }, +] + +[package.metadata] +requires-dist = [ + { name = "orjson", specifier = ">=3.11.3" }, + { name = "urllib3", specifier = ">=2.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "certifi", specifier = ">=2025.10.5" }, + { name = "coverage", specifier = ">=7.11.0" }, + { name = "mypy", specifier = ">=1.18.2" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytz", specifier = ">=2025.2" }, + { name = "ruff", specifier = ">=0.14.2" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "stopit", specifier = ">=1.1.2" }, + { name = "verlib2", specifier = ">=0.3.1" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/64/4a3cef001c6cd9c64256348d4c13a7b09b857e3e1cbb5185917df67d8ced/orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7", size = 238600, upload-time = "2025-08-26T17:44:36.875Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/0c8c87f54f79d051485903dc46226c4d3220b691a151769156054df4562b/orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120", size = 123526, upload-time = "2025-08-26T17:44:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/249497e861f2d438f45b3ab7b7b361484237414945169aa285608f9f7019/orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467", size = 128075, upload-time = "2025-08-26T17:44:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/e5/64/00485702f640a0fd56144042a1ea196469f4a3ae93681871564bf74fa996/orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873", size = 130483, upload-time = "2025-08-26T17:44:41.788Z" }, + { url = "https://files.pythonhosted.org/packages/64/81/110d68dba3909171bf3f05619ad0cf187b430e64045ae4e0aa7ccfe25b15/orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a", size = 132539, upload-time = "2025-08-26T17:44:43.12Z" }, + { url = "https://files.pythonhosted.org/packages/79/92/dba25c22b0ddfafa1e6516a780a00abac28d49f49e7202eb433a53c3e94e/orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b", size = 135390, upload-time = "2025-08-26T17:44:44.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/1d/ca2230fd55edbd87b58a43a19032d63a4b180389a97520cc62c535b726f9/orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf", size = 132966, upload-time = "2025-08-26T17:44:45.719Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b9/96bbc8ed3e47e52b487d504bd6861798977445fbc410da6e87e302dc632d/orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4", size = 131349, upload-time = "2025-08-26T17:44:46.862Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3c/418fbd93d94b0df71cddf96b7fe5894d64a5d890b453ac365120daec30f7/orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc", size = 404087, upload-time = "2025-08-26T17:44:48.079Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a9/2bfd58817d736c2f63608dec0c34857339d423eeed30099b126562822191/orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569", size = 146067, upload-time = "2025-08-26T17:44:49.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/ba/29023771f334096f564e48d82ed855a0ed3320389d6748a9c949e25be734/orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6", size = 135506, upload-time = "2025-08-26T17:44:50.558Z" }, + { url = "https://files.pythonhosted.org/packages/39/62/b5a1eca83f54cb3aa11a9645b8a22f08d97dbd13f27f83aae7c6666a0a05/orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc", size = 136352, upload-time = "2025-08-26T17:44:51.698Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c0/7ebfaa327d9a9ed982adc0d9420dbce9a3fec45b60ab32c6308f731333fa/orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770", size = 131539, upload-time = "2025-08-26T17:44:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/360674cd817faef32e49276187922a946468579fcaf37afdfb6c07046e92/orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f", size = 238238, upload-time = "2025-08-26T17:44:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/05/3d/5fa9ea4b34c1a13be7d9046ba98d06e6feb1d8853718992954ab59d16625/orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91", size = 127713, upload-time = "2025-08-26T17:44:55.596Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/e18367823925e00b1feec867ff5f040055892fc474bf5f7875649ecfa586/orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904", size = 123241, upload-time = "2025-08-26T17:44:57.185Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/3c66b91c4564759cf9f473251ac1650e446c7ba92a7c0f9f56ed54f9f0e6/orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6", size = 127895, upload-time = "2025-08-26T17:44:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/82/b5/dc8dcd609db4766e2967a85f63296c59d4722b39503e5b0bf7fd340d387f/orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d", size = 130303, upload-time = "2025-08-26T17:44:59.491Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/d58ec5fd1270b2aa44c862171891adc2e1241bd7dab26c8f46eb97c6c6f1/orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038", size = 132366, upload-time = "2025-08-26T17:45:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/0ef7e22eb8dd1ef940bfe3b9e441db519e692d62ed1aae365406a16d23d0/orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb", size = 135180, upload-time = "2025-08-26T17:45:02.424Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/e5bf7b70883f374710ad74faf99bacfc4b5b5a7797c1d5e130350e0e28a3/orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2", size = 132741, upload-time = "2025-08-26T17:45:03.663Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0c/4577fd860b6386ffaa56440e792af01c7882b56d2766f55384b5b0e9d39b/orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55", size = 131104, upload-time = "2025-08-26T17:45:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/66/4b/83e92b2d67e86d1c33f2ea9411742a714a26de63641b082bdbf3d8e481af/orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1", size = 403887, upload-time = "2025-08-26T17:45:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/9eea6a14e9b5ceb4a271a1fd2e1dec5f2f686755c0fab6673dc6ff3433f4/orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824", size = 145855, upload-time = "2025-08-26T17:45:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/8d4f5ad0c80ba9bf8ac4d0fc71f93a7d0dc0844989e645e2074af376c307/orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f", size = 135361, upload-time = "2025-08-26T17:45:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5f/16386970370178d7a9b438517ea3d704efcf163d286422bae3b37b88dbb5/orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204", size = 136190, upload-time = "2025-08-26T17:45:10.962Z" }, + { url = "https://files.pythonhosted.org/packages/09/60/db16c6f7a41dd8ac9fb651f66701ff2aeb499ad9ebc15853a26c7c152448/orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b", size = 131389, upload-time = "2025-08-26T17:45:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2a/bb811ad336667041dea9b8565c7c9faf2f59b47eb5ab680315eea612ef2e/orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e", size = 126120, upload-time = "2025-08-26T17:45:13.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b0/a7edab2a00cdcb2688e1c943401cb3236323e7bfd2839815c6131a3742f4/orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b", size = 238259, upload-time = "2025-08-26T17:45:15.093Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/ff4865a9cc398a07a83342713b5932e4dc3cb4bf4bc04e8f83dedfc0d736/orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2", size = 127633, upload-time = "2025-08-26T17:45:16.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e6/e00bea2d9472f44fe8794f523e548ce0ad51eb9693cf538a753a27b8bda4/orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a", size = 123061, upload-time = "2025-08-26T17:45:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/31/9fbb78b8e1eb3ac605467cb846e1c08d0588506028b37f4ee21f978a51d4/orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c", size = 127956, upload-time = "2025-08-26T17:45:19.172Z" }, + { url = "https://files.pythonhosted.org/packages/36/88/b0604c22af1eed9f98d709a96302006915cfd724a7ebd27d6dd11c22d80b/orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064", size = 130790, upload-time = "2025-08-26T17:45:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/1c1238ae9fffbfed51ba1e507731b3faaf6b846126a47e9649222b0fd06f/orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424", size = 132385, upload-time = "2025-08-26T17:45:22.036Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b5/c06f1b090a1c875f337e21dd71943bc9d84087f7cdf8c6e9086902c34e42/orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23", size = 135305, upload-time = "2025-08-26T17:45:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/a0/26/5f028c7d81ad2ebbf84414ba6d6c9cac03f22f5cd0d01eb40fb2d6a06b07/orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667", size = 132875, upload-time = "2025-08-26T17:45:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/b8df70d9cfb56e385bf39b4e915298f9ae6c61454c8154a0f5fd7efcd42e/orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f", size = 130940, upload-time = "2025-08-26T17:45:27.209Z" }, + { url = "https://files.pythonhosted.org/packages/da/5e/afe6a052ebc1a4741c792dd96e9f65bf3939d2094e8b356503b68d48f9f5/orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1", size = 403852, upload-time = "2025-08-26T17:45:28.478Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/7bbabafeb2ce65915e9247f14a56b29c9334003536009ef5b122783fe67e/orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc", size = 146293, upload-time = "2025-08-26T17:45:29.86Z" }, + { url = "https://files.pythonhosted.org/packages/27/b3/2d703946447da8b093350570644a663df69448c9d9330e5f1d9cce997f20/orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049", size = 135470, upload-time = "2025-08-26T17:45:31.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/b14dcfae7aff0e379b0119c8a812f8396678919c431efccc8e8a0263e4d9/orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca", size = 136248, upload-time = "2025-08-26T17:45:32.567Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/9e3127d65de7fff243f7f3e53f59a531bf6bb295ebe5db024c2503cc0726/orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1", size = 131437, upload-time = "2025-08-26T17:45:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/51/92/a946e737d4d8a7fd84a606aba96220043dcc7d6988b9e7551f7f6d5ba5ad/orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710", size = 125978, upload-time = "2025-08-26T17:45:36.422Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" }, + { url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" }, + { url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" }, + { url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" }, + { url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115, upload-time = "2025-08-26T17:46:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493, upload-time = "2025-08-26T17:46:03.466Z" }, + { url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998, upload-time = "2025-08-26T17:46:04.803Z" }, + { url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915, upload-time = "2025-08-26T17:46:06.237Z" }, + { url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907, upload-time = "2025-08-26T17:46:07.581Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852, upload-time = "2025-08-26T17:46:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309, upload-time = "2025-08-26T17:46:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424, upload-time = "2025-08-26T17:46:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266, upload-time = "2025-08-26T17:46:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351, upload-time = "2025-08-26T17:46:15.27Z" }, + { url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "stopit" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/58/e8bb0b0fb05baf07bbac1450c447d753da65f9701f551dca79823ce15d50/stopit-1.1.2.tar.gz", hash = "sha256:f7f39c583fd92027bd9d06127b259aee7a5b7945c1f1fa56263811e1e766996d", size = 18281, upload-time = "2018-02-09T00:32:14.204Z" } + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "verlib2" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/d4/3009a8d0d8b6d5b9eb94a86d8adb9335ab67c61e9658c7fc1c9dcce61363/verlib2-0.3.1.tar.gz", hash = "sha256:2862f19528db400d130253a2b71c7c3616ee14e1d54bf6833bc0929d2cddd141", size = 15522, upload-time = "2025-02-11T20:04:21.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/7a/1b537ad58e042cb6d1223ae6d8dfa330396cfd9d4095491615ee21b2edae/verlib2-0.3.1-py3-none-any.whl", hash = "sha256:cf8e2be044b834a2670f2d4c20a93cfc674933c0070543a6f61d531439cca200", size = 14216, upload-time = "2025-02-11T20:04:19.342Z" }, +] From 39ad8c961a0551a8a5fe1e104efea9a574ee5cea Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 17:58:56 +0200 Subject: [PATCH 20/23] Fix CI --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72f0eb44..cc8bf5dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,7 +61,7 @@ jobs: ./devtools/setup_ci.sh # Bootstrap environment. - # source bootstrap.sh + source bootstrap.sh # Run linter. uv run ruff check . From 051625928e909919e32c58f563ca6de1ce297e92 Mon Sep 17 00:00:00 2001 From: surister Date: Fri, 24 Oct 2025 18:12:01 +0200 Subject: [PATCH 21/23] Fix tests --- tests/client/test_http.py | 2 +- tests/client/test_serialization.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/client/test_http.py b/tests/client/test_http.py index fb4af879..bdd4ffce 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -284,7 +284,7 @@ def test_client_multithreaded(): ] num_threads = 5 num_commands = 1000 - thread_timeout = 5.0 # seconds + thread_timeout = 10.0 # seconds gate = Event() error_queue = queue.Queue() diff --git a/tests/client/test_serialization.py b/tests/client/test_serialization.py index c5a4a474..44cc4784 100644 --- a/tests/client/test_serialization.py +++ b/tests/client/test_serialization.py @@ -116,8 +116,8 @@ def test_uuid_serialization(): We do not care about specific uuid versions, just the object that is re-used across all versions of the uuid module. """ + uuid_int = 50583033507982468033520929066863110751 data = uuid.UUID( - bytes=(50583033507982468033520929066863110751).to_bytes(16), - version=4) + bytes=uuid_int.to_bytes(16, byteorder='big'), version=4) result = json_dumps(data) assert result == b'"260df019-a183-431f-ad46-115ccdf12a5f"' From 65255bc32cbbfe271f5df0ef5b3ea24b815801f9 Mon Sep 17 00:00:00 2001 From: surister Date: Mon, 27 Oct 2025 19:17:54 +0100 Subject: [PATCH 22/23] Renew default __repr__ implementation --- src/crate/client/connection.py | 2 +- tests/client/test_connection.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/crate/client/connection.py b/src/crate/client/connection.py index b0a2a15b..0638a018 100644 --- a/src/crate/client/connection.py +++ b/src/crate/client/connection.py @@ -208,7 +208,7 @@ def _lowest_server_version(self): return lowest or Version("0.0.0") def __repr__(self): - return "".format(repr(self.client)) + return f"<{self.__class__.__qualname__} {self.client!r}>" def __enter__(self): return self diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py index 8d4f897f..374faf77 100644 --- a/tests/client/test_connection.py +++ b/tests/client/test_connection.py @@ -75,6 +75,12 @@ def test_connection_mock(): "my server", "0.42.0") +def test_default_repr(): + """ + Verify default repr dunder method. + """ + conn = connect() + assert repr(conn) == ">" def test_with_timezone(): """ From 10bfaec0b3140a78f2d4da75c9dbf4c3505b57da Mon Sep 17 00:00:00 2001 From: surister Date: Mon, 27 Oct 2025 19:18:08 +0100 Subject: [PATCH 23/23] Add more tests --- tests/client/test_connection.py | 33 +++++++++++++++++++++++++++++++++ tests/client/test_cursor.py | 18 +++++++----------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py index 374faf77..fbc43e66 100644 --- a/tests/client/test_connection.py +++ b/tests/client/test_connection.py @@ -1,10 +1,12 @@ import datetime from unittest.mock import MagicMock, patch +import pytest from urllib3 import Timeout from crate.client import connect from crate.client.connection import Connection +from crate.client.exceptions import ProgrammingError from crate.client.http import Client from .settings import crate_host @@ -28,6 +30,37 @@ def test_lowest_server_version(): assert (1, 0, 3) == connection.lowest_server_version.version + +def test_connection_closes_access(): + """ + Verify that a connection closes on exit and that it also closes + the client. + """ + with patch('crate.client.connection.Client', + spec=Client, + return_value=MagicMock()) as client: + + conn = connect() + conn.close() + + assert conn._closed + client.assert_called_once() + + # Should raise an exception if + # we try to access a cursor now. + with pytest.raises(ProgrammingError): + conn.cursor() + + with pytest.raises(ProgrammingError): + conn.commit() + +def test_connection_closes_context_manager(): + """Verify that the context manager of the client closes the connection""" + with patch.object(connect, 'close', autospec=True) as close_fn: + with connect(): + pass + close_fn.assert_called_once() + def test_invalid_server_version(): """ Verify that when no correct version is set, diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index 3425e40a..67b8ab15 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -141,7 +141,6 @@ def test_execute_with_args(mocked_connection): cursor.execute(statement, 1) mocked_connection.client.sql.assert_called_once_with(statement, 1, None) - def test_execute_with_bulk_args(mocked_connection): """ Verify that `cursor.execute` is called with the right parameters @@ -177,10 +176,9 @@ def test_execute_custom_converter(mocked_connection): "duration": 123, } - with mock.patch.object( - mocked_connection.client, - 'sql', - return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") result = cursor.fetchall() @@ -222,8 +220,7 @@ def test_execute_with_converter_and_invalid_data_type(mocked_connection): with mock.patch.object( mocked_connection.client, 'sql', - return_value=response - ): + return_value=response): cursor.execute("") with pytest.raises(ValueError) as e: cursor.fetchone() @@ -240,10 +237,9 @@ def test_execute_array_with_converter(mocked_connection): "rowcount": 1, "duration": 123, } - with mock.patch.object( - mocked_connection.client, - 'sql', - return_value=response): + with mock.patch.object(mocked_connection.client, + 'sql', + return_value=response): cursor.execute("") result = cursor.fetchone()