From d5062f6e8cc340af25c988962cccd60c990f81de Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 1/9] DRIVER-379: Record whether the local datacenter was configured The configuration report has to tell a datacenter the user chose from one the driver inferred, and DCAwareRoundRobinPolicy cannot: on_up() assigns the inferred datacenter to the same local_dc attribute the constructor set, so from the first host coming up the two are indistinguishable. Capture it at construction instead, where an empty local_dc counts as inferred -- which is what makes on_up() infer. RackAwareRoundRobinPolicy needs no such flag: both values are mandatory constructor arguments and are never reassigned. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 6 +++++ cassandra/policies.py | 25 ++++++++++++++++--- tests/unit/test_policies.py | 48 +++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 068be2e048..8cab8591fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,12 @@ Features Others ------ +* ``DCAwareRoundRobinPolicy.local_dc`` is now read-only. It is set by the constructor, + and filled in by the policy itself when the constructor was given none, from the first + host to come up. Assigning it afterwards was indistinguishable from that inference, + and the two mean different things: a datacenter the application chose against one the + driver guessed. Code that assigned it should pass ``local_dc`` to the constructor + instead. * The ``STARTUP`` options that describe the driver itself are no longer the application's to set. An ``ApplicationInfoBase.add_startup_options`` that sets ``DRIVER_NAME``, ``DRIVER_VERSION``, ``SESSION_ID`` or ``DRIVER_CONFIG`` now has that diff --git a/cassandra/policies.py b/cassandra/policies.py index f1bfefb41d..ee24b4b5a8 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -222,9 +222,24 @@ class DCAwareRoundRobinPolicy(LoadBalancingPolicy): datacenters as a last resort. """ - local_dc = None + _local_dc = None + _local_dc_explicit = False used_hosts_per_remote_dc = 0 + @property + def local_dc(self): + """ + The datacenter treated as local, whether it was configured through the + constructor or inferred from the first host to come up. + + Read-only. It is the constructor's to set, and on_up()'s to fill in when + the constructor was given nothing: an assignment afterwards would be + indistinguishable from that inference, and telling the two apart is the + difference between a datacenter an application chose and one the driver + guessed. + """ + return self._local_dc + def __init__(self, local_dc='', used_hosts_per_remote_dc=0): """ The `local_dc` parameter should be the name of the datacenter @@ -241,7 +256,11 @@ def __init__(self, local_dc='', used_hosts_per_remote_dc=0): rest will be considered :attr:`~.HostDistance.IGNORED`. By default, all remote hosts are ignored. """ - self.local_dc = local_dc + self._local_dc = local_dc + # Whether the datacenter was chosen here or is left to on_up() to infer. + # An empty local_dc is the default rather than a choice, which is also + # what makes on_up() infer one. + self._local_dc_explicit = bool(local_dc) self.used_hosts_per_remote_dc = used_hosts_per_remote_dc self._dc_live_hosts = {} self._position = 0 @@ -295,7 +314,7 @@ def on_up(self, host): # not worrying about threads because this will happen during # control connection startup/refresh if not self.local_dc and host.datacenter: - self.local_dc = host.datacenter + self._local_dc = host.datacenter log.info("Using datacenter '%s' for DCAwareRoundRobinPolicy (via host '%s'); " "if incorrect, please specify a local_dc to the constructor, " "or limit contact points to local cluster nodes" % diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index 35c1a96f87..2fc31a31df 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -570,6 +570,54 @@ def test_default_dc(self): policy.on_add(host_remote) assert policy.local_dc + def test_local_dc_explicit(self): + """ + The configured/inferred distinction has to survive inference, since + that is the only point at which the two look alike. + """ + assert DCAwareRoundRobinPolicy('local')._local_dc_explicit + assert not DCAwareRoundRobinPolicy()._local_dc_explicit + # An empty datacenter is what the default is, so it is not a choice. + assert not DCAwareRoundRobinPolicy('')._local_dc_explicit + + host_local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'local', host_id=uuid.uuid4()) + cluster = Mock(endpoints_resolved=[DefaultEndPoint(1)]) + + policy = DCAwareRoundRobinPolicy() + policy.populate(cluster, [host_local]) + policy.on_add(host_local) + assert policy.local_dc == 'local' + assert not policy._local_dc_explicit + + def test_a_subclass_that_declares_a_datacenter_keeps_it(self): + """ + local_dc is a read-only property, and a subclass is still free to + shadow it with a plain class attribute. Doing so declares a datacenter, + so inference leaves it alone -- on_up only fills in one that is unset. + """ + class Pinned(DCAwareRoundRobinPolicy): + local_dc = 'configured' + + host_local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'local', host_id=uuid.uuid4()) + + policy = Pinned() + policy.on_up(host_local) + + assert policy.local_dc == 'configured' + + def test_the_datacenter_is_read_only(self): + """ + Set through the constructor and nowhere else. An assignment afterwards + would be indistinguishable from on_up's inference, and the two mean + different things to anything reading the policy. + """ + policy = DCAwareRoundRobinPolicy('dc1') + + with pytest.raises(AttributeError): + policy.local_dc = 'dc2' + + assert policy.local_dc == 'dc1' + class TokenAwarePolicyTest(unittest.TestCase): def test_wrap_round_robin(self): From d08191535244a7c09aa3628c3a11a17621b98097 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 27 Aug 2026 12:20:23 +0200 Subject: [PATCH 2/9] DRIVER-379: Derive the connection limits for a subclass that lowers them Connection.max_request_id and Connection.orphaned_threshold are both derived from max_in_flight in the class body -- which runs once. A subclass that lowers max_in_flight inherits values derived from this class's, not from its own. For orphaned_threshold that leaves a max_in_flight of 256 paired with a threshold of 24576. A connection can hold no more orphaned stream ids than it has request ids, so the count never reaches the threshold and orphan-based replacement never happens for such a subclass at all -- the safety mechanism is there and silently dead. max_in_flight is documented as the knob for lower-level integrations that want an upper bound without reimplementing the connection, so it is meant to be lowered. Derived per subclass in __init_subclass__ rather than per instance, because the pool reads both off the class, and the configuration report describes them before any connection exists. A subclass that sets either itself keeps it: that is a deliberate choice rather than something to derive over. max_request_id moves to the class for the second reason. It was computed in __init__, so anything wanting the limit before a connection existed had to restate how it is computed -- and three places did, the report and two of its tests. Halving the expression in __init__ left every one of them agreeing with each other and disagreeing with the driver, with the whole suite passing. Deriving it once removes the copies rather than adding a fourth. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 9 +++++++ cassandra/connection.py | 29 +++++++++++++++++++- tests/unit/test_connection.py | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8cab8591fd..2967f033d6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,15 @@ Others and the two mean different things: a datacenter the application chose against one the driver guessed. Code that assigned it should pass ``local_dc`` to the constructor instead. +* ``Connection.max_request_id`` and ``Connection.orphaned_threshold`` are now derived + again for a subclass that lowers ``max_in_flight``. Both are computed from it in the + class body, which runs once, so a subclass previously inherited values derived from the + base class -- leaving, for example, a ``max_in_flight`` of 256 with a threshold of + 24576, which a connection holding at most 256 orphaned stream ids can never reach. + Orphan-based connection replacement therefore never happened for such a subclass. A + subclass that sets either itself keeps it. ``max_request_id`` also moves from the + instance to the class, so that it can be read before a connection exists; its value is + unchanged. * The ``STARTUP`` options that describe the driver itself are no longer the application's to set. An ``ApplicationInfoBase.add_startup_options`` that sets ``DRIVER_NAME``, ``DRIVER_VERSION``, ``SESSION_ID`` or ``DRIVER_CONFIG`` now has that diff --git a/cassandra/connection.py b/cassandra/connection.py index af95891a3b..241c7cfbf5 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -849,6 +849,30 @@ class Connection(object): # owning pool (currently, only HostConnection supports this) orphaned_threshold = 3 * max_in_flight // 4 + # The highest request id a connection will hand out. Request ids run from + # zero to this inclusive, and borrow_connection admits a request only while + # in_flight is below it. Capped at the CQL stream id range, which is all the + # protocol can address however high max_in_flight is set. + max_request_id = min(max_in_flight - 1, (2 ** 15) - 1) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # Both of these are derived from max_in_flight, so both have to be + # derived again for a subclass that lowers it. The expressions above run + # once, when this class body is executed, and a subclass would otherwise + # inherit values computed from *this* class's max_in_flight -- leaving, + # say, a max_in_flight of 256 with a threshold of 24576, which a + # connection holding at most 256 orphans can never reach. Orphan-based + # replacement would then never happen for it at all. + # + # A subclass that sets either itself keeps it: that is a deliberate + # choice, not something to derive over. + if 'max_in_flight' in cls.__dict__: + if 'orphaned_threshold' not in cls.__dict__: + cls.orphaned_threshold = 3 * cls.max_in_flight // 4 + if 'max_request_id' not in cls.__dict__: + cls.max_request_id = min(cls.max_in_flight - 1, (2 ** 15) - 1) + is_defunct = False is_closed = False lock = None @@ -944,7 +968,10 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None, if not self.ssl_context and self.ssl_options: self.ssl_context = self._build_ssl_context_from_options() - self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1) + # max_request_id is derived on the class, so that the configuration + # report can describe the limit before any connection exists without + # restating how it is computed. + # # Don't fill the deque with 2**15 items right away. Start with some and add # more if needed. initial_size = min(300, self.max_in_flight) diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 5962db1189..d6c4852498 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -414,6 +414,56 @@ def test_wait_for_responses_shutdown_includes_last_error(self): assert "Bad file descriptor" in error_message +class DerivedConnectionLimitsTest(unittest.TestCase): + """ + max_request_id and orphaned_threshold are derived from max_in_flight, and the deriving + expression in the class body runs once. A subclass lowering max_in_flight + has to derive it again or inherit a threshold its connections can never + reach, which would leave orphan-based replacement never happening for it. + """ + + def test_the_default_threshold(self): + assert Connection.orphaned_threshold == 3 * Connection.max_in_flight // 4 + + def test_a_subclass_that_lowers_the_limit_derives_its_own(self): + class Small(Connection): + max_in_flight = 256 + + assert Small.max_request_id == 255 + assert Small.orphaned_threshold == 192 + # The point of deriving it: a connection can hold no more orphans than + # it has request ids, so an inherited 24576 would never be reached. + assert Small.orphaned_threshold < Small.max_in_flight + + def test_a_subclass_keeps_what_it_sets_itself(self): + class Explicit(Connection): + max_in_flight = 256 + orphaned_threshold = 10 + max_request_id = 7 + + assert Explicit.orphaned_threshold == 10 + assert Explicit.max_request_id == 7 + + def test_an_instance_carries_the_limit_derived_on_its_class(self): + """ + It used to be recomputed in __init__, which left the configuration + report -- built before any connection exists -- restating how it is + computed and free to drift from it. + """ + connection = Connection.__new__(Connection) + + assert connection.max_request_id == Connection.max_request_id + assert Connection.max_request_id == min(Connection.max_in_flight - 1, (2 ** 15) - 1) + + def test_a_subclass_that_changes_neither_inherits_both(self): + class Untouched(Connection): + pass + + assert Untouched.max_in_flight == Connection.max_in_flight + assert Untouched.orphaned_threshold == Connection.orphaned_threshold + assert Untouched.max_request_id == Connection.max_request_id + + class StartupOptionsTest(unittest.TestCase): """ Covers the options the driver puts in the STARTUP frame, by driving a From da061c06ec2984946e9688d2a43b23ed33a1b656 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 3/9] DRIVER-379: Vendor the report schema and validate against it The DRIVER_CONFIG report is a cross-driver contract: an operator reading system.clients.client_options relies on the same document whichever driver wrote the row. That contract is a JSON Schema maintained upstream, vendored here byte for byte -- and pinned as such by a test -- so drift from the shared copy shows up as a diff rather than as a divergence nobody notices. Validating is worth the dependency because every group in the schema is additionalProperties: false, so a key this driver invents or misspells fails hard instead of being silently dropped by a consumer. These tests cover the harness and the contract, not the reporter, whose report is still only {"version":1}: connection, control-plane and query are all required, so it becomes conformant once the last of those groups lands. Landing the schema first means every commit in between is checked against it. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 1 + tests/driver_config_schema.py | 66 ++ tests/resources/driver-config-schema-v1.json | 1026 ++++++++++++++++++ tests/unit/test_driver_config_schema.py | 191 ++++ uv.lock | 515 +++++++++ 5 files changed, 1799 insertions(+) create mode 100644 tests/driver_config_schema.py create mode 100644 tests/resources/driver-config-schema-v1.json create mode 100644 tests/unit/test_driver_config_schema.py diff --git a/pyproject.toml b/pyproject.toml index 0d7a042d0e..3bd44aa3c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dev = [ "numpy", "objgraph", "coverage[toml]>=7.6", + "jsonschema>=4.18", "ccm @ git+https://git@github.com/scylladb/scylla-ccm.git@master", ] diff --git a/tests/driver_config_schema.py b/tests/driver_config_schema.py new file mode 100644 index 0000000000..b69627998f --- /dev/null +++ b/tests/driver_config_schema.py @@ -0,0 +1,66 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed 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. +""" +Validation of the ``DRIVER_CONFIG`` report against the schema shared by the +ScyllaDB drivers. + +The schema is the cross-driver contract: it is what an operator reading +``system.clients.client_options`` can rely on, whichever driver wrote the row. +Every group it defines is ``additionalProperties: false``, so a key this driver +invents, or misspells, is a validation failure rather than something a consumer +silently ignores. +""" + +import json +import os + +import jsonschema + +SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'resources', + 'driver-config-schema-v1.json') +""" +Vendored copy of the normative schema, byte for byte as it appears upstream, +which is where it is maintained: + + https://github.com/scylladb/gocql/blob/master/docs/driver-config-schema.json + +Vendored rather than reformatted, so that a drift from the shared contract shows +up as a diff in this file instead of as a divergence nobody notices. +""" + + +def load_schema(): + """ + Returns the parsed schema. Not cached: the callers are tests, and a mutable + document shared between them is a worse trade than re-reading a 30 KiB file. + """ + with open(SCHEMA_PATH, encoding='utf8') as f: + return json.load(f) + + +def validate_report(report): + """ + Validates a configuration report against the schema, raising + :exc:`jsonschema.ValidationError` if it does not conform. + + `report` is either the JSON text of the ``DRIVER_CONFIG`` option, as it goes + on the wire and comes back out of the clients table, or an already parsed + document. Returns the parsed document, so a test can go on to assert + specific values against the thing that was validated. + """ + if isinstance(report, (str, bytes)): + report = json.loads(report) + + jsonschema.validate(instance=report, schema=load_schema()) + return report diff --git a/tests/resources/driver-config-schema-v1.json b/tests/resources/driver-config-schema-v1.json new file mode 100644 index 0000000000..64f8ad6ce8 --- /dev/null +++ b/tests/resources/driver-config-schema-v1.json @@ -0,0 +1,1026 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scylladb.com/schemas/driver-client-options/v1.json", + "title": "ScyllaDB driver DRIVER_CONFIG configuration", + "description": "Schema for the JSON value sent under the STARTUP option key DRIVER_CONFIG, describing the effective client configuration. The top-level object must include `version` and the required configuration groups listed by this schema. Unknown top-level keys are rejected. Built-in groups reject unknown keys and require the keys listed in each group; custom policy objects may include additional implementation-specific public attributes where explicitly allowed.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "connection", + "control-plane", + "query" + ], + "properties": { + "version": { + "description": "Major schema version. Adding keys is backward-compatible and does not bump this; only changing/removing the meaning of an existing key does.", + "type": "integer", + "const": 1 + }, + "connection": { + "$ref": "#/$defs/connection" + }, + "control-plane": { + "$ref": "#/$defs/control-plane" + }, + "query": { + "$ref": "#/$defs/query" + } + }, + "$defs": { + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "retryPolicyBackoff": { + "description": "Delay inserted between retry attempts of a retry policy. Discriminated union: when present, `type` selects the backoff algorithm and each algorithm carries only its own parameters. Absent when there is no delay between attempts.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff: the delay starts at base-ms and doubles after each attempt (capped at max-ms), with a small random jitter to de-synchronize concurrent retries. When max-ms is present, it MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "additionalProperties": false, + "required": [ + "type", + "base-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Exponential backoff algorithm." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay between retries in milliseconds; the starting delay that doubles each attempt." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between retries in milliseconds; the exponentially growing delay is capped here. MUST be greater than or equal to base-ms. Absent when no maximum delay is configured." + } + } + }, + { + "type": "object", + "description": "Constant backoff: wait a fixed, strictly positive delay between every retry attempt.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Constant (fixed-delay) backoff algorithm." + }, + "delay-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Fixed delay between retries in milliseconds. Must be greater than 0; omit backoff when no delay is configured." + } + } + } + ] + }, + "requests": { + "type": "object", + "description": "Per-connection CQL request and protocol stream capacity. `orphaned.max` is expected to be lower than `in-flight.max`.", + "additionalProperties": false, + "required": [ + "in-flight" + ], + "properties": { + "in-flight": { + "type": "object", + "description": "Requests currently awaiting a response on the connection.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of concurrent in-flight requests allowed on one connection." + } + } + }, + "orphaned": { + "type": "object", + "description": "Requests that the client stopped waiting for but whose stream identifiers cannot yet be safely reused.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of orphaned requests allowed on one connection before the driver closes and replaces it. Absent only when this bound is unknown, for example when the client never replaces a connection over accumulated orphans and so has no limit to report." + } + } + } + } + }, + "connection-pool": { + "description": "Connection pooling configuration.", + "type": "object", + "required": [ + "shard-aware" + ], + "additionalProperties": false, + "properties": { + "shard-aware": { + "type": "object", + "required": [ + "enabled" + ], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the client is configured to use ScyllaDB's dedicated shard-aware port (default 19042, TLS 19043) to reach a chosen shard in a single connect, versus the fallback of opening connections on the normal port and reading the server-assigned shard. Reports configuration intent; at runtime the port must also be advertised by the server and reachable, otherwise the client falls back transparently." + } + } + } + } + }, + "connection": { + "description": "Connection-level settings: socket read/write/connect timeouts plus the CQL-level idle heartbeat. Durations are in milliseconds. Optional duration fields are absent when unset or not applicable.", + "type": "object", + "required": [ + "connect", + "requests", + "pool", + "socket", + "reconnection" + ], + "additionalProperties": false, + "properties": { + "requests": { + "$ref": "#/$defs/requests" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster driver holds connections to." + }, + "connect": { + "type": "object", + "description": "Settings for establishing a TCP/CQL connection to a node.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Timeout for establishing a TCP/CQL connection to a node." + } + } + }, + "read": { + "type": "object", + "description": "Settings for reading from a connection.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Read operation timeout." + } + } + }, + "write": { + "type": "object", + "description": "Settings for writing to a connection. Direction-specific options such as write coalescing are expected to be added here in a future schema version.", + "additionalProperties": false, + "properties": { + "coalescing": { + "type": "object", + "description": "Settings for write coalescing. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + }, + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Write operation timeout." + } + } + }, + "heartbeat": { + "type": "object", + "description": "Reserved for CQL-level idle heartbeat settings. Optional and intentionally empty in this schema version. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + }, + "pool": { + "$ref": "#/$defs/connection-pool", + "description": "A connection pooling configuration." + }, + "socket": { + "$ref": "#/$defs/socket" + }, + "reconnection": { + "description": "Connection reconnection configuration.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/reconnection-policy" + } + } + }, + "tls": { + "$ref": "#/$defs/tls" + } + } + }, + "control-plane": { + "description": "Control-plane timeout settings for internal/system queries run over the control connection and for schema agreement. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "type": "object", + "required": [ + "queries", + "schema" + ], + "additionalProperties": false, + "properties": { + "queries": { + "type": "object", + "description": "Control-plane query settings.", + "additionalProperties": false, + "required": [ + "system" + ], + "properties": { + "system": { + "type": "object", + "description": "Settings for internal/system queries run over the control connection.", + "additionalProperties": false, + "required": [ + "timeout" + ], + "properties": { + "timeout": { + "type": "object", + "description": "Timeouts applied to internal/system queries. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "additionalProperties": false, + "properties": { + "client-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A client-side timeout for internal queries." + }, + "server-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A server-side timeout for internal queries." + } + } + } + } + } + } + }, + "schema": { + "type": "object", + "description": "Control-plane schema settings.", + "additionalProperties": false, + "required": [ + "agreement" + ], + "properties": { + "agreement": { + "type": "object", + "description": "Settings for schema agreement across nodes.", + "additionalProperties": false, + "required": [ + "timeout-ms" + ], + "properties": { + "timeout-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum time to wait for schema agreement across nodes. Always a concrete value; 0 means do not wait for agreement." + } + } + } + } + } + } + }, + "socket": { + "description": "Low-level TCP socket options applied to client connections. Boolean options (tcp-no-delay, keep-alive, reuse-address) report the effective on/off state: when no explicit value is configured, the OS/platform default is reported. Buffer sizes are in bytes and linger is in seconds; these fields are absent when unset (kernel auto-tuned buffer / linger disabled).", + "type": "object", + "required": [ + "tcp-no-delay", + "keep-alive", + "reuse-address" + ], + "additionalProperties": false, + "properties": { + "tcp-no-delay": { + "type": "boolean", + "description": "TCP_NODELAY: disable Nagle's algorithm. Reports the effective value; when no explicit value is configured, the OS/platform default is reported." + }, + "keep-alive": { + "type": "boolean", + "description": "SO_KEEPALIVE: OS-level TCP keep-alive probes on idle connections. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "reuse-address": { + "type": "boolean", + "description": "SO_REUSEADDR: allow reuse of a local address. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "linger": { + "type": "object", + "required": [ + "interval-s" + ], + "additionalProperties": false, + "properties": { + "interval-s": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "SO_LINGER lingering-close interval in seconds." + } + } + }, + "receive-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_RCVBUF socket receive buffer size hint in bytes." + } + } + }, + "send-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_SNDBUF socket send buffer size hint in bytes." + } + } + } + } + }, + "reconnection-policy": { + "description": "Defines how connection attempts to a node are retried after a connection failure.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff reconnection policy. max-ms MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "additionalProperties": false, + "required": [ + "type", + "base-ms", + "max-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Reconnection policy type." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay before the first reconnection attempt in milliseconds. Always a concrete value when this policy is reported." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between reconnection attempts in milliseconds. MUST be greater than or equal to base-ms. Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "Constant-delay reconnection policy. A delay of 0 means reconnect immediately.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Reconnection policy type." + }, + "delay-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Fixed delay between reconnection attempts in milliseconds; 0 means reconnect immediately. Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "A user-supplied reconnection policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Reconnection policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + } + } + }, + { + "type": "null", + "description": "No reconnection attempts will be made." + } + ] + }, + "retry-policy": { + "description": "Controls whether and how a failed query is retried. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Standard error-aware retry policy.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "standard-error-aware", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "Simple retry policy with a fixed number of retries.", + "additionalProperties": false, + "required": [ + "type", + "max-retries" + ], + "properties": { + "type": { + "const": "simple", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up. Always a concrete value when this policy is reported; 0 means no retries." + } + } + }, + { + "type": "object", + "description": "Fall-through retry policy: never retries anything and always rethrows the original error to the caller. Every error type — read timeout, write timeout, unavailable, and unexpected request errors (connection errors, Overloaded, ServerError, Bootstrapping) — is propagated unchanged. This is a true no-op and is stricter than the 'never' policy, which still retries the next host on connection/server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "fallthrough", + "description": "Retry policy type." + } + } + }, + { + "type": "object", + "description": "Never-retry policy: does not retry read timeouts, write timeouts, or unavailable errors, but may try the next host for connection and server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "never", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "Downgrading-consistency retry policy: retries at a lower consistency level on failure.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "downgrading-consistency", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "type": "object", + "description": "A user-supplied retry policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Retry policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + } + ] + }, + "speculative-execution-policy": { + "description": "Controls pre-emptive duplicate requests to other replicas. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Constant-delay speculative execution: launch extra executions after a fixed delay. A delay of 0 means launch them immediately.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "delay-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Delay before launching each additional execution in milliseconds; 0 means launch immediately." + } + } + }, + { + "type": "object", + "description": "Percentile-based speculative execution: launch extra executions once latency exceeds a percentile threshold.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "percentile" + ], + "properties": { + "type": { + "const": "percentile", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "percentile": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100, + "description": "Latency percentile (0–100, exclusive; e.g. 99.0) that triggers an additional execution." + } + } + }, + { + "type": "object", + "description": "A user-supplied speculative execution policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Speculative execution policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "adaptive-ordering": { + "type": "object", + "description": "Dynamic reordering of otherwise eligible candidate nodes using runtime responsiveness, load, or health observations. Absent when adaptive ordering is disabled. This capability does not imply a particular algorithm.", + "additionalProperties": false, + "required": [ + "signals" + ], + "properties": { + "signals": { + "type": "array", + "description": "Runtime observations used to influence ordering.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "latency", + "response-rate", + "in-flight-requests", + "recovery-state" + ] + } + } + } + }, + "load-balancing-policy": { + "description": "Load balancing / host selection policy, discriminated by `type`. A built-in token-aware policy is reported with `type` set to `token-aware` and the normalized capability flags below. A user-supplied policy is reported with `type` set to `custom`, a `name`, and, optionally, serialized public attributes.", + "oneOf": [ + { + "type": "object", + "description": "A built-in load balancing policy, reported with normalized location/awareness flags.", + "additionalProperties": false, + "required": [ + "type", + "load-distribution", + "fallback-to-non-preferred-nodes" + ], + "properties": { + "type": { + "const": "token-aware", + "description": "Load balancing policy type: the built-in token-aware policy." + }, + "load-distribution": { + "type": "string", + "enum": [ + "shuffle", + "round-robin", + "replica-set" + ], + "description": "Strategy used to distribute requests across otherwise equally preferred nodes. `shuffle` randomizes node selection across query plans; `round-robin` rotates the first selected node across successive query plans; `replica-set` preserves the replica set's existing order without reordering it." + }, + "fallback-to-non-preferred-nodes": { + "type": "boolean", + "description": "Whether requests may fail over to nodes outside of the preference configured by `query.load-balancing.node-preference`." + }, + "adaptive-ordering": { + "$ref": "#/$defs/adaptive-ordering" + } + } + }, + { + "type": "object", + "description": "A user-supplied load balancing policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Load balancing policy type: a user-supplied policy." + }, + "name": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "node-location-preference": { + "description": "Session-level datacenter/rack preference, set independently of the load balancing policy. Some implementations let users set a preferred DC/rack directly on the session configuration; the load balancing policy and other components read this preference unless a policy overrides it. May be sourced from different places; if DC/rack preferences are specified in the load balancing policy, they should be reported here.", + "oneOf": [ + { + "type": "object", + "description": "Explicitly configured datacenter preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc" + ], + "properties": { + "type": { + "const": "dc", + "description": "Session-level location preference: explicit datacenter." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + } + } + }, + { + "type": "object", + "description": "Explicitly configured datacenter and rack preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc", + "local-rack" + ], + "properties": { + "type": { + "const": "rack", + "description": "Session-level location preference: explicit datacenter and rack." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred rack." + } + } + }, + { + "type": "object", + "description": "Datacenter preference inferred from the first node the client connects to.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "dc-auto", + "description": "Session-level location preference: inferred datacenter." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known at report time." + } + } + }, + { + "type": "object", + "description": "Datacenter and/or rack preference inferred from the connected node. Configured and inferred values are reported separately.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "rack-auto", + "description": "At least one part of the location preference is inferred." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred rack." + }, + "inferred-local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known." + }, + "inferred-local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred rack. Absent when not yet known." + } + }, + "allOf": [ + { + "not": { + "required": [ + "local-dc", + "inferred-local-dc" + ] + } + }, + { + "not": { + "required": [ + "local-rack", + "inferred-local-rack" + ] + } + }, + { + "not": { + "required": [ + "local-dc", + "local-rack" + ] + } + } + ] + } + ] + }, + "query": { + "description": "Query execution configuration.", + "type": "object", + "required": [ + "defaults", + "retry", + "load-balancing" + ], + "additionalProperties": false, + "properties": { + "defaults": { + "$ref": "#/$defs/query-defaults" + }, + "retry": { + "description": "Query retry configuration. Backoff is optional and is omitted when no retry delay is configured.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "policy": { + "properties": { + "type": { + "const": "fallthrough" + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "policy" + ] + }, + "then": { + "not": { + "required": [ + "backoff" + ] + } + } + } + ], + "properties": { + "policy": { + "$ref": "#/$defs/retry-policy" + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries. Omitted when no retry backoff is configured. Every configured delay must be greater than 0." + } + } + }, + "load-balancing": { + "description": "Load-balancing configuration applied to queries.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/load-balancing-policy" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster queries will be scheduled on" + } + } + }, + "speculative-execution": { + "description": "Speculative-execution configuration applied to queries. Absent when speculative execution is disabled.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/speculative-execution-policy" + } + } + } + } + }, + "query-defaults": { + "description": "Default per-request settings applied to statements that do not override them.", + "type": "object", + "required": [ + "consistency", + "idempotence" + ], + "additionalProperties": false, + "properties": { + "page": { + "type": "object", + "required": [ + "size" + ], + "additionalProperties": false, + "properties": { + "size": { + "$ref": "#/$defs/positiveInteger", + "description": "Default page (fetch) size for result sets. Absent when page is not limited." + } + } + }, + "consistency": { + "description": "Default consistency level applied to requests that do not override it. Always present when this group is reported.", + "type": "string", + "enum": [ + "ANY", + "ONE", + "TWO", + "THREE", + "QUORUM", + "ALL", + "LOCAL_QUORUM", + "EACH_QUORUM", + "LOCAL_ONE", + "SERIAL", + "LOCAL_SERIAL" + ] + }, + "serial-consistency": { + "description": "Default serial consistency for LWT/conditional statements. Absent when unset; the server default applies.", + "type": "string", + "enum": [ + "SERIAL", + "LOCAL_SERIAL" + ] + }, + "idempotence": { + "description": "Default idempotence flag applied to statements that do not set their own.", + "type": "boolean" + }, + "client-timestamps": { + "description": "True when the client assigns the write timestamp client-side (protocol-level/USING TIMESTAMP) instead of letting the coordinator assign it. Absent only when this behavior is unknown, for example when a custom timestamp generator may or may not enforce a timestamp.", + "type": "boolean" + }, + "request": { + "type": "object", + "description": "Default request-level settings.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Client-side timeout for a single request/query in milliseconds. Absent when the timeout is disabled or unset." + } + } + } + } + }, + "tls": { + "description": "TLS/SSL transport settings. Absent when TLS is disabled. Reports only booleans; never credentials, keys, or host lists.", + "type": "object", + "additionalProperties": false, + "properties": { + "hostname-verification": { + "type": "boolean", + "description": "Whether the server hostname is verified against its certificate. Absent only when this behavior is unknown, for example when a custom certificate validator may or may not enforce hostname verification." + } + } + } + } +} diff --git a/tests/unit/test_driver_config_schema.py b/tests/unit/test_driver_config_schema.py new file mode 100644 index 0000000000..46c5d82fdc --- /dev/null +++ b/tests/unit/test_driver_config_schema.py @@ -0,0 +1,191 @@ +# Copyright 2026 ScyllaDB, Inc. +# +# Licensed 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. +""" +Tests of the vendored schema and of the helper that validates against it. + +These do not exercise :class:`~.DriverConfigReporter`; they establish that the +contract is being enforced at all, so that the tests which do exercise it are +worth something. What the driver actually reports is checked in +``test_driver_config.py``. +""" + +import copy +import json +import unittest + +import jsonschema +import pytest + +from tests.driver_config_schema import SCHEMA_PATH, load_schema, validate_report + +MINIMAL_REPORT = { + 'version': 1, + 'connection': { + 'connect': {}, + 'requests': {'in-flight': {'max': 1}}, + 'pool': {'shard-aware': {'enabled': False}}, + 'socket': {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False}, + 'reconnection': {'policy': None}, + }, + 'control-plane': { + 'queries': {'system': {'timeout': {}}}, + 'schema': {'agreement': {'timeout-ms': 0}}, + }, + 'query': { + 'defaults': {'consistency': 'LOCAL_ONE', 'idempotence': False}, + 'retry': {'policy': {'type': 'fallthrough'}}, + 'load-balancing': {'policy': {'type': 'custom', 'name': 'X'}}, + }, +} +""" +The smallest document the schema accepts: every required group, and in each one +only the required keys. Spelled out rather than generated, so that a change to +what the shared contract demands shows up here as a diff. +""" + + +class DriverConfigSchemaTest(unittest.TestCase): + def test_the_vendored_schema_is_the_shared_one(self): + """ + The schema is vendored from upstream, where it is maintained. Its $id is + what a consumer keys off, so a copy that lost it is not the contract. + """ + schema = load_schema() + + assert schema['$id'] == 'https://scylladb.com/schemas/driver-client-options/v1.json' + assert schema['$schema'] == 'https://json-schema.org/draft/2020-12/schema' + + def test_the_vendored_schema_is_itself_valid(self): + jsonschema.Draft202012Validator.check_schema(load_schema()) + + def test_the_vendored_copy_is_byte_for_byte(self): + """ + Vendored verbatim, so that drift from upstream is a diff in the resource + rather than a reinterpretation. Reformatting it would defeat that, so + this pins the formatting the shared copy has. + """ + with open(SCHEMA_PATH, encoding='utf8') as f: + raw = f.read() + + assert raw.startswith('{\n "$schema"') + assert raw.endswith('}\n') + # Reserialising with the shared copy's formatting must be a no-op. The + # descriptions contain em dashes, which the shared copy leaves as they + # are rather than escaping. + assert json.dumps(json.loads(raw), indent=2, ensure_ascii=False) + '\n' == raw + + def test_the_minimal_report_validates(self): + assert validate_report(MINIMAL_REPORT) == MINIMAL_REPORT + + def test_a_report_is_accepted_as_wire_text(self): + """ + The helper takes what goes on the wire and comes back out of the clients + table, not only an already parsed document. + """ + assert validate_report(json.dumps(MINIMAL_REPORT)) == MINIMAL_REPORT + + def _rejects(self, mutate): + report = copy.deepcopy(MINIMAL_REPORT) + mutate(report) + with pytest.raises(jsonschema.ValidationError): + validate_report(report) + + def test_unknown_keys_are_rejected(self): + """ + Every built-in group is additionalProperties: false, so a key this driver + invents or misspells fails validation instead of being ignored by a + consumer. This is the property that makes the schema worth validating + against at all. + """ + def top_level(report): + report['made-up'] = 1 + + def inside_a_group(report): + report['connection']['made-up'] = 1 + + def inside_a_nested_group(report): + report['query']['defaults']['made-up'] = 1 + + for mutate in (top_level, inside_a_group, inside_a_nested_group): + self._rejects(mutate) + + def test_missing_required_groups_are_rejected(self): + for group in ('connection', 'control-plane', 'query'): + self._rejects(lambda report, group=group: report.pop(group)) + + def test_a_foreign_schema_version_is_rejected(self): + for version in (0, 2, '1'): + self._rejects(lambda report, version=version: report.__setitem__('version', version)) + + def test_out_of_range_numbers_are_rejected(self): + def zero_in_flight(report): + # positiveInteger: 0 in-flight requests would describe a connection + # that cannot carry a request. + report['connection']['requests']['in-flight']['max'] = 0 + + def negative_agreement_timeout(report): + # nonNegativeInteger: 0 is meaningful here, below that is not. + report['control-plane']['schema']['agreement']['timeout-ms'] = -1 + + for mutate in (zero_in_flight, negative_agreement_timeout): + self._rejects(mutate) + + def test_unknown_enum_members_are_rejected(self): + self._rejects(lambda report: report['query']['defaults'].__setitem__('consistency', 'MOSTLY')) + + def test_a_consistency_level_is_not_a_number(self): + """ + The wire form of a consistency level is an integer and the schema wants + the name, which is the mistake this driver is closest to making. + """ + self._rejects(lambda report: report['query']['defaults'].__setitem__('consistency', 4)) + + def test_discriminated_unions_reject_foreign_parameters(self): + def constant_delay_on_an_exponential_policy(report): + report['connection']['reconnection']['policy'] = { + 'type': 'exponential', 'base-ms': 1, 'max-ms': 2, 'delay-ms': 3} + + def a_custom_policy_without_a_name(report): + report['query']['load-balancing']['policy'] = {'type': 'custom'} + + for mutate in (constant_delay_on_an_exponential_policy, a_custom_policy_without_a_name): + self._rejects(mutate) + + def test_backoff_is_rejected_on_a_fallthrough_retry_policy(self): + """ + A policy that never retries cannot have a delay between retries. The + schema says so conditionally, which is the one rule a producer is likely + to break without noticing. + """ + self._rejects(lambda report: report['query']['retry'].__setitem__( + 'backoff', {'type': 'constant', 'delay-ms': 1})) + + def test_the_orphan_bound_is_optional_but_permitted(self): + """ + The shared schema leaves connection.requests.orphaned optional, for a + client with nothing bounding its orphaned requests. This driver has such + a bound in Connection.orphaned_threshold and reports it; optional is not + forbidden, so both documents have to validate. + """ + without = copy.deepcopy(MINIMAL_REPORT) + assert 'orphaned' not in without['connection']['requests'] + validate_report(without) + + with_bound = copy.deepcopy(MINIMAL_REPORT) + with_bound['connection']['requests']['orphaned'] = {'max': 0} + validate_report(with_bound) + + # Present but empty is still a violation: the group exists to carry the + # bound, so it may be absent but not uninformative. + self._rejects(lambda report: report['connection']['requests'].__setitem__('orphaned', {})) diff --git a/uv.lock b/uv.lock index 216e808caa..d8822e0ad5 100644 --- a/uv.lock +++ b/uv.lock @@ -1604,6 +1604,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version >= '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kerberos" version = "1.3.1" @@ -2581,6 +2635,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2621,6 +2714,425 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9.12' and python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.9.12'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, + { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, + { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6c/252e83e1ce7583c81f26d1d884b2074d40a13977e1b6c9c50bbf9a7f1f5a/rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527", size = 372140, upload-time = "2025-08-27T12:15:05.441Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/949c195d927c5aeb0d0629d329a20de43a64c423a6aa53836290609ef7ec/rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d", size = 354086, upload-time = "2025-08-27T12:15:07.404Z" }, + { url = "https://files.pythonhosted.org/packages/9f/02/e43e332ad8ce4f6c4342d151a471a7f2900ed1d76901da62eb3762663a71/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8", size = 382117, upload-time = "2025-08-27T12:15:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/05/b0fdeb5b577197ad72812bbdfb72f9a08fa1e64539cc3940b1b781cd3596/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc", size = 394520, upload-time = "2025-08-27T12:15:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/67/1f/4cfef98b2349a7585181e99294fa2a13f0af06902048a5d70f431a66d0b9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1", size = 522657, upload-time = "2025-08-27T12:15:12.613Z" }, + { url = "https://files.pythonhosted.org/packages/44/55/ccf37ddc4c6dce7437b335088b5ca18da864b334890e2fe9aa6ddc3f79a9/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125", size = 402967, upload-time = "2025-08-27T12:15:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/74/e5/5903f92e41e293b07707d5bf00ef39a0eb2af7190aff4beaf581a6591510/rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905", size = 384372, upload-time = "2025-08-27T12:15:15.842Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e3/fbb409e18aeefc01e49f5922ac63d2d914328430e295c12183ce56ebf76b/rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e", size = 401264, upload-time = "2025-08-27T12:15:17.388Z" }, + { url = "https://files.pythonhosted.org/packages/55/79/529ad07794e05cb0f38e2f965fc5bb20853d523976719400acecc447ec9d/rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e", size = 418691, upload-time = "2025-08-27T12:15:19.144Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/6554a7fd6d9906fda2521c6d52f5d723dca123529fb719a5b5e074c15e01/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786", size = 558989, upload-time = "2025-08-27T12:15:21.087Z" }, + { url = "https://files.pythonhosted.org/packages/19/b2/76fa15173b6f9f445e5ef15120871b945fb8dd9044b6b8c7abe87e938416/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec", size = 589835, upload-time = "2025-08-27T12:15:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/5560a4b39bab780405bed8a88ee85b30178061d189558a86003548dea045/rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b", size = 555227, upload-time = "2025-08-27T12:15:24.278Z" }, + { url = "https://files.pythonhosted.org/packages/52/d7/cd9c36215111aa65724c132bf709c6f35175973e90b32115dedc4ced09cb/rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52", size = 217899, upload-time = "2025-08-27T12:15:25.926Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e0/d75ab7b4dd8ba777f6b365adbdfc7614bbfe7c5f05703031dfa4b61c3d6c/rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab", size = 228725, upload-time = "2025-08-27T12:15:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, + { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, + { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, + { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, + { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5463cd5048a7a2fcdae308b6e96432802132c141bfb9420260142632a0f1/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475", size = 371778, upload-time = "2025-08-27T12:16:13.851Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/f38c099db07f5114029c1467649d308543906933eebbc226d4527a5f4693/rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f", size = 354394, upload-time = "2025-08-27T12:16:15.609Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/b76f97704d9dd8ddbd76fed4c4048153a847c5d6003afe20a6b5c3339065/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6", size = 382348, upload-time = "2025-08-27T12:16:17.251Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3f/ef23d3c1be1b837b648a3016d5bbe7cfe711422ad110b4081c0a90ef5a53/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3", size = 394159, upload-time = "2025-08-27T12:16:19.251Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/9e62693af1a34fd28b1a190d463d12407bd7cf561748cb4745845d9548d3/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3", size = 522775, upload-time = "2025-08-27T12:16:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/8d5bb122bf7a60976b54c5c99a739a3819f49f02d69df3ea2ca2aff47d5c/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8", size = 402633, upload-time = "2025-08-27T12:16:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0e/237948c1f425e23e0cf5a566d702652a6e55c6f8fbd332a1792eb7043daf/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400", size = 384867, upload-time = "2025-08-27T12:16:24.29Z" }, + { url = "https://files.pythonhosted.org/packages/d6/0a/da0813efcd998d260cbe876d97f55b0f469ada8ba9cbc47490a132554540/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485", size = 401791, upload-time = "2025-08-27T12:16:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/c6c9e8a8aaca416a6f0d1b6b4a6ee35b88fe2c5401d02235d0a056eceed2/rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1", size = 419525, upload-time = "2025-08-27T12:16:27.659Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/5af37e1d71487cf6d56dd1420dc7e0c2732c1b6ff612aa7a88374061c0a8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5", size = 559255, upload-time = "2025-08-27T12:16:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/40/7f/8b7b136069ef7ac3960eda25d832639bdb163018a34c960ed042dd1707c8/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4", size = 590384, upload-time = "2025-08-27T12:16:31.005Z" }, + { url = "https://files.pythonhosted.org/packages/d8/06/c316d3f6ff03f43ccb0eba7de61376f8ec4ea850067dddfafe98274ae13c/rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c", size = 555959, upload-time = "2025-08-27T12:16:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/384cf54c430b9dac742bbd2ec26c23feb78ded0d43d6d78563a281aec017/rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859", size = 228784, upload-time = "2025-08-27T12:16:34.428Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -2701,6 +3213,8 @@ dev = [ { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, { name = "cryptography", version = "50.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "cython" }, + { name = "jsonschema", version = "4.25.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jsonschema", version = "4.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -2733,6 +3247,7 @@ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, { name = "cython", specifier = ">=3.2" }, + { name = "jsonschema", specifier = ">=4.18" }, { name = "numpy" }, { name = "objgraph" }, { name = "packaging", specifier = ">=25.0" }, From e10b030267ea19f11d1536e29f76ba0476822a18 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 4/9] DRIVER-379: Give the config reporter the cluster and the Scylla flag The configuration groups that follow need the cluster whose settings they describe, and one of them needs to know whether the node is a ScyllaDB one. This puts both in place without changing what is reported. The cluster is held weakly: it owns the reporter and hands it to every connection it opens, so a strong reference here would keep it alive for as long as any connection holds a reporter. Finding it gone is a shutdown race rather than a misconfiguration, so the option is left out at debug level. is_scylla is passed in rather than discovered, because the connection already knows -- _handle_options_response parses SUPPORTED into self.features before it builds these options -- and the predicate is the one the driver itself keys ScyllaDB-only behaviour off, so the report describes what the driver will do rather than only what it was configured to do. It is required: the sole caller always knows, and a default would let a wrong answer through quietly. The connection tests move to a stub report, so that what they establish -- which connections carry the report, and that an application cannot supply its own -- does not break on every configuration group that lands next. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/cluster.py | 2 +- cassandra/connection.py | 8 +++- cassandra/driver_config.py | 41 ++++++++++++++---- tests/unit/test_cluster.py | 12 +++++- tests/unit/test_connection.py | 17 ++++---- tests/unit/test_driver_config.py | 74 ++++++++++++++++++++++++++------ tests/unit/utils.py | 38 ++++++++++++++-- 7 files changed, 154 insertions(+), 38 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 7260bd08b6..bffe8695c2 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1520,7 +1520,7 @@ def __init__(self, # Built whatever the flag says, so that the flag is the only thing that # decides whether a connection reports: see _make_connection_kwargs. The # reporter holds no state, so an unused one costs nothing. - self._driver_config_reporter = DriverConfigReporter() + self._driver_config_reporter = DriverConfigReporter(self) self.control_connection = ControlConnection( self, self.control_connection_timeout, diff --git a/cassandra/connection.py b/cassandra/connection.py index 241c7cfbf5..ca1390c79f 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1590,7 +1590,13 @@ def _handle_options_response(self, options_response): # only the control connection reports it. A reporter left as None means # the cluster has configuration reporting disabled. if self.is_control_connection and self._driver_config_reporter is not None: - self._driver_config_reporter.add_startup_options(options) + # Whether this is a ScyllaDB node is already known: the features + # above were parsed from the SUPPORTED response, and sharding info + # is what the driver itself keys ScyllaDB-only behaviour off (see + # ControlConnection._try_connect), so the report describes what the + # driver will actually do rather than only what it was configured to. + self._driver_config_reporter.add_startup_options( + options, is_scylla=self.features.sharding_info is not None) if self.cql_version: if self.cql_version not in supported_cql_versions: diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index af5bf276f2..4734012cfd 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -20,6 +20,7 @@ import json import logging +import weakref log = logging.getLogger(__name__) @@ -81,10 +82,21 @@ class DriverConfigReporter: :meth:`cassandra.connection.Connection._handle_options_response`, not here. """ - def add_startup_options(self, options): + def __init__(self, cluster): + # Weak, because the cluster owns the reporter and hands it to every + # connection it opens: a strong reference here would run back through + # each of them and keep the cluster alive for as long as any connection + # holds a reporter. + self._cluster = weakref.ref(cluster) + + def add_startup_options(self, options, is_scylla): """ Adds the configuration report to the ``STARTUP`` options being built. + `is_scylla` says whether the node this connection is being established + to is a ScyllaDB one, which decides the keys that describe behaviour the + driver only has against ScyllaDB. + Reporting is best effort: this runs while a connection is being established, so a report that cannot be built or does not fit is logged and left out rather than allowed to fail the connection. @@ -96,7 +108,16 @@ def add_startup_options(self, options): left in ``options`` either. """ try: - report = self._build_report() + cluster = self._cluster() + if cluster is None: + # The application dropped its Cluster while this connection was + # being established. Nothing is wrong and nothing is worth + # warning about: the connection is on its way out too. + log.debug("The cluster is gone, its configuration will not be " + "reported on this connection") + return + + report = self._build_report(cluster, is_scylla) length = len(report.encode('utf8')) if length > MAX_DRIVER_CONFIG_LENGTH: log.warning("The driver configuration report is %d bytes long, which exceeds the " @@ -109,22 +130,24 @@ def add_startup_options(self, options): log.warning("Unable to build the driver configuration report, " "it will not be reported to the cluster", exc_info=True) - def _build_report(self): + def _build_report(self, cluster, is_scylla): """ - Returns the JSON configuration report. + Returns the JSON configuration report of `cluster`. It is built for every control connection rather than cached, so that it - always describes the configuration as it is at that point in time. Later - configuration groups may well describe state that is only known once the - cluster has been contacted. + always describes the configuration as it is at that point in time. Some + of what it describes is only known once a connection has got this far: + `is_scylla` comes out of the ``SUPPORTED`` response, and a datacenter + the driver inferred rather than was given is not known until the first + host comes up. """ report = {'version': DRIVER_CONFIG_SCHEMA_VERSION} - self._populate_report(report) + self._populate_report(report, cluster, is_scylla) # Separators without whitespace: the report is a wire value bounded by # MAX_DRIVER_CONFIG_LENGTH, not something meant to be read as it is. return json.dumps(report, separators=(',', ':')) - def _populate_report(self, report): + def _populate_report(self, report, cluster, is_scylla): """ Extension point for adding the configuration groups themselves to the report. Empty for now. diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 35dc354465..62e346016a 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -319,6 +319,16 @@ def test_connection_factory_reports_the_session_id_and_the_configuration(self): assert factory.call_args.kwargs['session_id'] == cluster.session_id assert isinstance(factory.call_args.kwargs['driver_config_reporter'], DriverConfigReporter) + def test_the_reporter_describes_the_cluster_that_owns_it(self): + """ + The reporter reads its configuration off the cluster when a connection + asks for the report, so it has to be pointed at the one that owns it. + """ + cluster = Cluster() + self.addCleanup(cluster.shutdown) + + assert cluster._driver_config_reporter._cluster() is cluster + def test_driver_config_reporting_can_be_toggled_after_construction(self): """ The flag is a plain published attribute, so it is read when a connection @@ -375,7 +385,7 @@ def test_connection_factory_ignores_a_caller_supplied_session_id_and_reporter(se cluster = Cluster(driver_config_reporting_enabled=False) self.addCleanup(cluster.shutdown) cluster.connection_factory(endpoint, session_id=uuid.uuid4(), - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=DriverConfigReporter(cluster)) assert factory.call_args.kwargs['session_id'] == cluster.session_id assert factory.call_args.kwargs['driver_config_reporter'] is None diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index d6c4852498..c92121b801 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -26,14 +26,13 @@ locally_supported_compressions, ConnectionHeartbeat, HeartbeatFuture, _Frame, Timer, TimerManager, ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator, DRIVER_NAME, DRIVER_VERSION) -from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, - DRIVER_CONFIG_SCHEMA_VERSION, SESSION_ID_OPTION) +from cassandra.driver_config import DRIVER_CONFIG_OPTION, SESSION_ID_OPTION from cassandra.marshal import uint8_pack, uint32_pack, int32_pack from cassandra.protocol import (write_stringmultimap, write_int, write_string, read_stringmap, SupportedMessage, ProtocolHandler, ResultMessage, RESULT_KIND_SET_KEYSPACE) -from tests.unit.utils import ThrowingReporter +from tests.unit.utils import StubReporter, ThrowingReporter from tests.util import wait_until, assertRegex import pytest @@ -537,7 +536,7 @@ def add_startup_options(self, options): ABSENT = object() cases = [ ("a pool connection reports no configuration at all", - {'driver_config_reporter': DriverConfigReporter()}, + {'driver_config_reporter': StubReporter()}, ABSENT), ("nor does a control connection with reporting disabled", {'is_control_connection': True}, @@ -546,8 +545,8 @@ def add_startup_options(self, options): {'is_control_connection': True, 'driver_config_reporter': ThrowingReporter()}, ABSENT), ("the driver's own report wins where there is one", - {'is_control_connection': True, 'driver_config_reporter': DriverConfigReporter()}, - '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION), + {'is_control_connection': True, 'driver_config_reporter': StubReporter()}, + StubReporter.REPORT), ] for description, kwargs, expected in cases: @@ -635,9 +634,9 @@ def add_startup_options(self, options): def test_driver_config_is_reported_on_the_control_connection(self): options = self.startup_options(is_control_connection=True, - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=StubReporter()) - assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + assert options[DRIVER_CONFIG_OPTION] == StubReporter.REPORT def test_driver_config_is_not_reported_on_a_regular_connection(self): """ @@ -646,7 +645,7 @@ def test_driver_config_is_not_reported_on_a_regular_connection(self): session id that ties them to it. """ options = self.startup_options(session_id=self.SESSION_ID, - driver_config_reporter=DriverConfigReporter()) + driver_config_reporter=StubReporter()) assert SESSION_ID_OPTION in options assert DRIVER_CONFIG_OPTION not in options diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index e9d94c92fc..079356c01f 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -12,38 +12,56 @@ # See the License for the specific language governing permissions and # limitations under the License. +import gc import json import unittest +from unittest import mock +from unittest.mock import Mock from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH) -from tests.unit.utils import ThrowingReporter +from tests.unit.utils import _ClusterlessReporter, ThrowingReporter -class OversizedReporter(DriverConfigReporter): +class OversizedReporter(_ClusterlessReporter): """ Produces a report one byte past the limit. The schema-only report built by :class:`.DriverConfigReporter` cannot reach the limit on its own, so the guard is only reachable through a subclass. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): return 'a' * (MAX_DRIVER_CONFIG_LENGTH + 1) -class MistypedReporter(DriverConfigReporter): +class MistypedReporter(_ClusterlessReporter): """ Returns something that is not a string, the mistake the ``_populate_report`` extension point invites once it describes more than the schema version. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): return None +def reporter(cluster=None): + """ + A reporter over `cluster`, defaulting to one whose configuration is never + read because nothing populates the report yet. + + The cluster is kept alive for as long as the reporter is: it is held weakly, + so a temporary would be collected before the report is built and every test + here would silently exercise the cluster-is-gone path instead. + """ + cluster = cluster if cluster is not None else Mock() + r = DriverConfigReporter(cluster) + r._strong_cluster = cluster + return r + + class DriverConfigReporterTest(unittest.TestCase): def test_reports_the_schema_version(self): options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert json.loads(options[DRIVER_CONFIG_OPTION]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} @@ -54,7 +72,7 @@ def test_report_is_compact_json(self): """ options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION @@ -66,7 +84,7 @@ def test_report_fits_within_the_length_limit(self): """ options = {} - DriverConfigReporter().add_startup_options(options) + reporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION in options, \ "the report was dropped, it must have exceeded the length limit" @@ -76,7 +94,7 @@ def test_report_fits_within_the_length_limit(self): def test_oversized_report_is_not_reported(self): options = {} - OversizedReporter().add_startup_options(options) + OversizedReporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options @@ -87,7 +105,7 @@ def test_failure_to_build_the_report_is_not_reported(self): """ options = {} - ThrowingReporter().add_startup_options(options) + ThrowingReporter().add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options @@ -99,15 +117,43 @@ def test_a_report_that_is_not_a_string_is_not_reported(self): """ options = {} - MistypedReporter().add_startup_options(options) + MistypedReporter().add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options + + def test_the_cluster_is_held_weakly(self): + """ + The cluster owns the reporter and hands it to every connection it opens, + so a strong reference here would run back through each of them and keep + the cluster alive for as long as any connection holds a reporter. + """ + cluster = Mock() + r = DriverConfigReporter(cluster) + assert r._cluster() is cluster + + del cluster + gc.collect() + assert r._cluster() is None + + def test_nothing_is_reported_once_the_cluster_is_gone(self): + """ + An application dropping its Cluster while a connection is being + established is a shutdown race, not a misconfiguration: the option is + left out and nothing is warned about. + """ + r = DriverConfigReporter(Mock()) + options = {} + + with mock.patch.object(r, '_cluster', return_value=None): + r.add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION not in options def test_other_options_are_left_alone(self): options = {'APPLICATION_NAME': 'app'} - OversizedReporter().add_startup_options(options) - MistypedReporter().add_startup_options(options) - DriverConfigReporter().add_startup_options(options) + OversizedReporter().add_startup_options(options, is_scylla=True) + MistypedReporter().add_startup_options(options, is_scylla=True) + reporter().add_startup_options(options, is_scylla=True) assert options['APPLICATION_NAME'] == 'app' diff --git a/tests/unit/utils.py b/tests/unit/utils.py index d843358225..85f9784b82 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -13,7 +13,7 @@ # limitations under the License. from functools import wraps -from unittest.mock import patch +from unittest.mock import Mock, patch from concurrent.futures import Future from cassandra.cluster import Session @@ -35,7 +35,23 @@ def wrapper(*args, **kwargs): return wrapper -class ThrowingReporter(DriverConfigReporter): +class _ClusterlessReporter(DriverConfigReporter): + """ + Base for the reporter doubles below, which override report building and so + never read the cluster. Supplying a Mock keeps them constructible without + one while leaving the weak reference the real reporter holds in place. + + That reference is why the Mock is also held strongly: a temporary would be + collected before the report is built, and the double would then drop its + report because the cluster was gone rather than for the reason it exists to + demonstrate -- which is a test that passes while proving nothing. + """ + def __init__(self, cluster=None): + self._strong_cluster = cluster if cluster is not None else Mock() + super().__init__(self._strong_cluster) + + +class ThrowingReporter(_ClusterlessReporter): """ A driver configuration reporter whose report cannot be built. @@ -44,5 +60,21 @@ class ThrowingReporter(DriverConfigReporter): guarantee that such a failure leaves the STARTUP frame otherwise intact instead of failing the connection. """ - def _build_report(self): + def _build_report(self, cluster, is_scylla): raise ValueError("simulated failure while building the report") + + +class StubReporter(_ClusterlessReporter): + """ + A driver configuration reporter with a fixed, recognisable report. + + The connection tests are about where the report goes -- which connections + carry it, and that an application cannot supply its own -- not about what is + in it. Asserting the real report's text there would tie those guarantees to + every configuration group that lands afterwards, and break them all at once + for a reason that has nothing to do with connections. + """ + REPORT = '{"stub-report":true}' + + def _build_report(self, cluster, is_scylla): + return self.REPORT From f17742cf8d0739e219e34927a0ec3444d4976025 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 5/9] DRIVER-379: Report the connection group What the driver does with a single connection: connect timeout, request capacity, shard-aware pooling, socket options, reconnection policy and TLS hostname verification. Three of the schema's optional groups are left out for want of anything to put in them -- this driver has no socket read or write timeout, and the heartbeat group is empty in this schema version, so idle_heartbeat_interval has nowhere to go -- worth raising for v2. orphaned is reported, since Connection.orphaned_threshold bounds them; the schema leaves the group out for a client where nothing does. Socket options are read from sockopts rather than off a live socket. That would be the effective value the schema asks for, but only some of this driver's six reactors expose a socket object -- asyncio holds a transport -- so the report would change shape with the reactor in use. The driver sets none of its own, so an option absent from sockopts is at the operating system's default, which for a fresh TCP socket is off. Policies dispatch on their exact type: a subclass of a built-in is a policy the driver knows nothing about, and describing it as its parent would put the parent's parameters against behaviour it does not have. A custom one is reported by name and nothing else, though the schema permits its public attributes too -- whatever it holds, an auth provider or a credential, would land in system.clients for anyone who can select from it, and there is no telling which attributes are safe. That also bounds the report, so no configuration can drive it past the size limit. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/cluster.py | 5 +- cassandra/driver_config.py | 451 +++++++++++++++++++- tests/unit/test_cluster.py | 18 + tests/unit/test_driver_config.py | 704 ++++++++++++++++++++++++++++++- 4 files changed, 1143 insertions(+), 35 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index bffe8695c2..02160b9a6f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1468,7 +1468,10 @@ def __init__(self, self.ssl_options = ssl_options self.ssl_context = ssl_context - self.sockopts = sockopts + # Materialized once: these are applied to every socket the cluster opens + # and are read again to build the configuration report, so a one-shot + # iterable would leave whichever consumer ran second with nothing at all. + self.sockopts = list(sockopts) if sockopts is not None else None self.cql_version = cql_version self.max_schema_agreement_wait = max_schema_agreement_wait self.control_connection_timeout = control_connection_timeout diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 4734012cfd..782cc12f85 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -20,7 +20,15 @@ import json import logging +import math +import operator +import socket +import struct import weakref +from itertools import repeat + +from cassandra.policies import (ConstantReconnectionPolicy, + ExponentialReconnectionPolicy) log = logging.getLogger(__name__) @@ -57,20 +65,340 @@ ``STARTUP`` options are serialized by :func:`cassandra.protocol.write_string`, which prefixes every value with a 16 bit length, so a longer value would fail to -pack and take the handshake down with it. The report is a handful of bytes for -now, but the configuration groups added later describe user supplied values, -such as the settings of custom policies, and can grow arbitrarily large. -Enforcing a limit here keeps "reporting must never prevent a connection from -being established" a property of this module rather than of the user's -configuration. - -32 KiB rather than the protocol's own 65535 byte ceiling: real world reports are -expected to stay well under a couple of kilobytes, so this leaves ample headroom -while remaining far short of the point where the value would stop protecting -anything. +pack and take the handshake down with it. + +Nothing in the report is user-supplied: a custom policy contributes its type name +and nothing else, see :func:`_custom_policy_report`. Its size is therefore a +function of the driver's own settings rather than of the configuration it +describes, and stays well under a couple of kilobytes. The limit is kept anyway, +so that "reporting must never prevent a connection from being established" stays +a property of this module -- a later group describing something unbounded would +otherwise make it a property of the user's configuration without anyone noticing. + +32 KiB rather than the protocol's own 65535 byte ceiling: that leaves ample +headroom while remaining far short of the point where the value would stop +protecting anything. """ +def _milliseconds(seconds): + """ + `seconds` in milliseconds, rounded to the nearest rather than truncated. + + Truncating loses a millisecond wherever the product lands just under its + integer, which binary floating point does often: 1.005 seconds multiplies + out to 1004.9999999999999, and reporting 1004 describes a timeout the + application did not set. 372 of the first 60000 whole milliseconds land that + way. + + Rounding does not disturb the sub-millisecond handling in the callers, since + everything below half a millisecond still arrives there as zero. + """ + return int(round(seconds * 1000)) + + +def _optional_ms(seconds): + """ + Milliseconds for a schema field of type ``positiveInteger``, or ``None`` + when the setting is unset or disabled and the key is to be left out. + + A configured duration below a millisecond reports as one rather than as + zero: it is a real setting, and zero is not a value the field can take. + """ + if seconds is None: + return None + ms = _milliseconds(seconds) + if ms < 1: + return 1 if seconds > 0 else None + return ms + + +def _required_ms(seconds): + """ + Milliseconds for a ``positiveInteger`` field the schema requires, so there + is no option of leaving it out: zero and below floor at one millisecond. + """ + ms = _optional_ms(seconds) + return 1 if ms is None else ms + + +def _non_negative_ms(seconds): + """ + Milliseconds for a ``nonNegativeInteger`` field, where zero is a value in + its own right -- "do not wait", "reconnect immediately", "launch + immediately" -- and is reported as it is rather than treated as unset. + + Which is why a configured duration below a millisecond reports as one rather + than truncating to zero: zero here does not mean "very little", it means the + driver skips the wait altogether, and the two are not the same claim. The + driver draws that line in the same place -- ControlConnection. + _wait_for_schema_agreement bypasses agreement only when its timeout is zero + or less -- so a sub-millisecond wait is one the driver really does take. + """ + if seconds is None: + return 0 + ms = _milliseconds(seconds) + if ms < 1: + return 1 if seconds > 0 else 0 + return ms + + +_SOCKET_FLAGS = ( + ('tcp-no-delay', socket.IPPROTO_TCP, socket.TCP_NODELAY), + ('keep-alive', socket.SOL_SOCKET, socket.SO_KEEPALIVE), + ('reuse-address', socket.SOL_SOCKET, socket.SO_REUSEADDR), +) + +_SOCKET_BUFFERS = ( + ('receive-buffer', socket.SOL_SOCKET, socket.SO_RCVBUF), + ('send-buffer', socket.SOL_SOCKET, socket.SO_SNDBUF), +) + + +def _linger_report(value): + """ + The ``linger`` group from the value of an ``SO_LINGER`` socket option. + + Unlike the other options this one is a packed ``struct linger`` -- two C + ``int``s, on and interval -- since that is what + :meth:`socket.socket.setsockopt` takes, so it has to be unpacked to be + described. Every buffer type that method accepts is accepted here, and only + the leading two ``int``s are read, for the same reason as in + :func:`_socket_option_int`. Anything that does not unpack is left out rather + than guessed at: it is the user's to get wrong when the connection applies + it. + """ + if not isinstance(value, (bytes, bytearray, memoryview)): + return None + raw = bytes(value) + if len(raw) < struct.calcsize('ii'): + return None + try: + onoff, interval = struct.unpack_from('ii', raw) + except struct.error: + return None + if not onoff or interval < 0: + return None + return {'interval-s': interval} + + +def _socket_option_int(value): + """ + The integer a socket option carries, or ``None`` when it carries something + this module cannot read. + + :meth:`socket.socket.setsockopt` takes an integer option either as an + ``int`` or as a packed buffer, and the kernel reads the two the same way, so + this has to as well. A packed buffer is a non-empty ``bytes``, so handing one + straight to :func:`bool` makes every option look enabled -- including one + packed to zero precisely to turn it off. + + What is decoded is the C ``int`` at the front of the buffer, in native size + and byte order, because that is what the kernel reads for these options: it + takes the leading ``int`` and ignores whatever follows. Reading the buffer + as one wide integer instead would answer for bytes the option never had -- + ``struct.pack('ii', 0, 1)`` is accepted for ``TCP_NODELAY`` and leaves it + off, while the whole eight bytes come to a non-zero number. + + A buffer too short to hold an ``int`` is one ``setsockopt`` itself rejects, + so there is nothing to report for it. + """ + if isinstance(value, (bytes, bytearray, memoryview)): + raw = bytes(value) + if len(raw) < struct.calcsize('i'): + return None + return struct.unpack_from('i', raw)[0] + # Anything else has to be an integer setsockopt would take. __index__ is + # what CPython's takes -- a numpy integer sets an option just as a builtin + # one does -- and operator.index returns a builtin int, so a bool does not + # travel on into the report as one where a number is expected. + # + # Some interpreters are more permissive: PyPy's setsockopt accepts a Decimal + # and the kernel sets the option from it, and such a value is reported here + # as unset. Unlike the reconnection limit, which asks itertools.repeat + # directly, there is no way to ask setsockopt without a socket to ask it on, + # and a value it rejects fails the connection before there is any report to + # be wrong -- so the gap only shows on an interpreter that takes it. + try: + return operator.index(value) + except TypeError: + return None + + +def _socket_report(sockopts): + """ + The ``connection.socket`` group. + + The driver sets no socket options of its own: ``sockopts`` is applied by + :meth:`cassandra.connection.Connection._connect_socket` and nothing else + touches them, so an option that is not in there is left at the operating + system's default. The three flags the schema requires are reported as off in + that case, which is what every platform this driver runs on defaults them + to for a fresh TCP socket. + """ + configured = {} + for opt in sockopts or (): + try: + level, name, value = opt + except (TypeError, ValueError): + # setsockopt also takes a (level, name, None, optlen) form, and an + # entry that is neither is the user's to get wrong at connect time, + # not this module's to report on. + continue + # Last one wins, as it does in the loop that applies them. + configured[(level, name)] = value + + report = {} + for key, level, name in _SOCKET_FLAGS: + report[key] = bool(_socket_option_int(configured.get((level, name)))) + for key, level, name in _SOCKET_BUFFERS: + size = _socket_option_int(configured.get((level, name))) + if size is not None and size > 0: + report[key] = {'size-bytes': size} + + linger = _linger_report(configured.get((socket.SOL_SOCKET, socket.SO_LINGER))) + if linger is not None: + report['linger'] = linger + return report + + +def _attempt_ceiling(limit): + """ + How many attempts a ``while i < limit`` loop makes, or ``None`` when `limit` + does not bound one. + + ``math.ceil`` rather than a check against particular numeric types: the loop + compares against anything an integer can be compared with, and this counts + anything that can say what its ceiling is. The result is coerced to a builtin + ``int`` so that no other numeric type reaches the report, where the schema + wants an integer. + """ + if limit is None: + return None + try: + attempts = int(math.ceil(limit)) + except (TypeError, ValueError, OverflowError): + return None + return attempts if attempts > 0 else None + + +def _constant_reconnection_attempts(max_attempts): + """ + How many reconnection attempts :class:`~.ConstantReconnectionPolicy` will + make, or ``None`` when it keeps trying or there is no count to report. + + ``new_schedule`` is ``repeat(delay, max_attempts)`` when `max_attempts` is + truthy and an unbounded ``repeat(delay)`` when it is not, so the falsy check + comes first: a zero there means unlimited, not none. + + Beyond that this asks :func:`itertools.repeat` itself, through the length it + reports, rather than testing the limit against a protocol. What ``repeat`` + accepts is not the same on every interpreter -- CPython wants ``__index__`` + and rejects a ``Decimal``, PyPy takes one and counts it -- so a driver + running on PyPy really does reconnect twice where the same configuration + raises on CPython. Asking the callee is the only way the report describes the + interpreter it is running on. + + A limit ``repeat`` will not take is a policy that raises when it reconnects, + and one too large for it to count is the same; neither has a count to report. + """ + if not max_attempts: + return None + try: + return operator.length_hint(repeat(None, max_attempts)) + except (TypeError, OverflowError): + return None + + +def _reconnection_policy_report(policy): + """ + The ``connection.reconnection.policy`` value. + + Dispatched on the exact type: a subclass of a built-in policy is a policy + the driver knows nothing about, and describing it as its parent would put + that parent's parameters against behaviour it does not have. + """ + if policy is None: + # The schema's way of saying that no reconnection will be attempted. + return None + + if type(policy) is ExponentialReconnectionPolicy: + if policy.max_attempts == 0: + # This policy's schedule is driven by + # `while max_attempts is None or i < max_attempts`, so zero attempts + # yields nothing at all and the driver never reconnects. That is the + # schema's null arm. Reporting an exponential policy with + # max-attempts left out would say the opposite, since the schema + # reads an absent max-attempts as unlimited. + return None + if not policy.base_delay: + # The curve collapses. The schedule is base_delay * 2 ** i, so a + # base of zero stays zero however many attempts are made and however + # high max_delay is: the driver reconnects immediately, every time. + # That is the constant arm with a delay of zero. The exponential arm + # cannot say it -- its base is a positiveInteger -- and reporting it + # there would claim a delay that grows when none ever does. + report = {'type': 'constant', 'delay-ms': 0} + else: + report = {'type': 'exponential', + 'base-ms': _required_ms(policy.base_delay), + 'max-ms': _required_ms(policy.max_delay)} + # Absent means unlimited, which is what a max_attempts of None is here. + # Anything else that bounds the loop is a real limit: new_schedule runs + # `while max_attempts is None or i < max_attempts`, which compares + # against whatever an integer can be compared with -- a fraction, a + # Decimal, a numpy integer -- and 1.5 admits an i of 0 and of 1, so two + # attempts are made. The count is therefore the ceiling of the limit, + # taken through math.ceil so that every such type is counted rather than + # a hand-written list of the ones thought of here. + attempts = _attempt_ceiling(policy.max_attempts) + if attempts is not None: + report['max-attempts'] = attempts + elif type(policy) is ConstantReconnectionPolicy: + # Read per policy rather than shared with the arm above, because the two + # read the same attribute with different code and disagree about the same + # value: see _constant_reconnection_attempts. + attempts = _constant_reconnection_attempts(policy.max_attempts) + if attempts == 0: + # repeat took the limit and made an empty schedule of it, which a + # negative limit does on every interpreter. The driver never + # reconnects, which is the null arm -- reporting a constant policy + # with max-attempts left out would say the opposite, since the schema + # reads an absent max-attempts as unlimited. + return None + + report = {'type': 'constant', 'delay-ms': _non_negative_ms(policy.delay)} + if attempts is not None: + # A builtin int, since that is what length_hint returns -- which is + # what keeps a limit of True out of the report as JSON true where a + # number belongs. + report['max-attempts'] = attempts + else: + # Only the name: see _custom_policy_report. + return _custom_policy_report(policy) + + return report + + +def _custom_policy_report(policy): + """ + A user-supplied policy, described by its type name and nothing else. + + The schema permits an implementation to serialize a custom policy's public + attributes as well, and this driver deliberately does not. A policy object + here is an arbitrary Python object whose ``__dict__`` is trivially + reachable, and whatever it happens to hold -- an auth provider, a + credential, a host list -- would go to the server, land in + ``system.clients``, and be readable by anyone who can select from it. There + is no way to tell which attributes are safe, so none of them are reported. + + Keeping user-supplied data out also bounds the report: what the driver sends + is a function of its own settings, so :const:`MAX_DRIVER_CONFIG_LENGTH` is + not something a configuration can drive it into. + """ + return {'type': 'custom', 'name': type(policy).__name__} + + class DriverConfigReporter: """ Builds the :const:`DRIVER_CONFIG_OPTION` ``STARTUP`` option describing the @@ -149,7 +477,102 @@ def _build_report(self, cluster, is_scylla): def _populate_report(self, report, cluster, is_scylla): """ - Extension point for adding the configuration groups themselves to the - report. Empty for now. + Adds the configuration groups themselves to the report. + """ + report['connection'] = self._connection_report(cluster) + + def _connection_report(self, cluster): + """ + The ``connection`` group: what the driver does with a single connection, + as opposed to what it does with a request. + + ``read`` and ``write`` are left out because this driver has no socket + read or write timeout to describe, and ``heartbeat`` because the group + the schema reserves for it is empty in this version, with nowhere to put + :attr:`~.Cluster.idle_heartbeat_interval`. + """ + connection_class = cluster.connection_class + # Read off the class rather than restated here, so that the report + # cannot drift from the limit it describes: it is derived on Connection + # for exactly this, since the report is built before any connection + # exists. + # + # This is the ceiling itself, not one below it: the admission gate in + # HostConnection.borrow_connection is `in_flight < max_request_id`, so a + # request is let through only while in_flight is under this. The pool of + # stream ids is one larger -- ids run from zero to max_request_id + # inclusive -- and reading the pool as the ceiling is the off-by-one this + # field invites. Connection.wait_for_responses, which serves internal + # multi-message waits rather than application queries, does admit one + # more. + max_request_id = connection_class.max_request_id + if max_request_id < 1: + # max_in_flight is documented as tunable by lower-level + # integrations. Tuned to one it leaves a connection whose gate never + # admits anything, and in-flight.max is a required positiveInteger + # with no way to say "none". + raise ValueError( + "connection_class.max_in_flight is %r, which leaves a connection " + "no capacity for a request; the configuration report cannot " + "describe a connection that admits none" + % (connection_class.max_in_flight,)) + + shard_aware_options = cluster.shard_aware_options + report = { + 'connect': {}, + 'requests': { + 'in-flight': {'max': max_request_id}, + # One below the threshold, for the mirror of the reason above. + # ResponseFuture._on_timeout adds the orphaned id and then tests + # `len(orphaned_request_ids) >= orphaned_threshold`, so a + # connection holding that many is already marked for + # replacement: the most it is ever allowed to hold, which is + # what the schema asks for, is one less. Never negative -- + # max_request_id below already refuses a connection class this + # small. + 'orphaned': {'max': connection_class.orphaned_threshold - 1}, + }, + 'pool': { + 'shard-aware': { + # Configuration intent, as the schema asks for: reaching a + # shard in one connect also needs the server to advertise + # the port and the client to be able to reach it, and the + # driver falls back transparently when it cannot. + 'enabled': not (shard_aware_options.disable + or shard_aware_options.disable_shardaware_port), + }, + }, + 'socket': _socket_report(cluster.sockopts), + 'reconnection': { + 'policy': _reconnection_policy_report(cluster.reconnection_policy), + }, + } + + connect_timeout_ms = _optional_ms(cluster.connect_timeout) + if connect_timeout_ms is not None: + report['connect']['timeout-ms'] = connect_timeout_ms + + tls = self._tls_report(cluster) + if tls is not None: + report['tls'] = tls + return report + + def _tls_report(self, cluster): + """ + The ``connection.tls`` group, or ``None`` when TLS is not configured. + + Booleans only: the schema is explicit that this group never carries + credentials, keys or host lists, and nothing here reads any. + + Hostname verification is always knowable in this driver. An explicit + ``ssl_context`` carries it as an attribute, and options on their own are + turned into a context by + :meth:`cassandra.connection.Connection._build_ssl_context_from_options`, + which reads the same key this does. """ - pass + if cluster.ssl_context is not None: + return {'hostname-verification': bool(getattr(cluster.ssl_context, + 'check_hostname', False))} + if cluster.ssl_options: + return {'hostname-verification': bool(cluster.ssl_options.get('check_hostname', False))} + return None diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 62e346016a..f6f666333f 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -319,6 +319,24 @@ def test_connection_factory_reports_the_session_id_and_the_configuration(self): assert factory.call_args.kwargs['session_id'] == cluster.session_id assert isinstance(factory.call_args.kwargs['driver_config_reporter'], DriverConfigReporter) + def test_sockopts_are_materialized(self): + """ + They are applied to every socket the cluster opens and are read again to + build the configuration report, so a one-shot iterable would leave + whichever consumer ran second with nothing -- the report claiming an + option is on while guaranteeing no connection ever sets it. + """ + cluster = Cluster(sockopts=((6, 1, 1) for _ in range(1))) + self.addCleanup(cluster.shutdown) + + # Twice: an iterable would be empty the second time round. + assert cluster.sockopts == [(6, 1, 1)] + assert cluster.sockopts == [(6, 1, 1)] + + unset = Cluster(sockopts=None) + self.addCleanup(unset.shutdown) + assert unset.sockopts is None + def test_the_reporter_describes_the_cluster_that_owns_it(self): """ The reporter reads its configuration off the cluster when a connection diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index 079356c01f..aadde0d586 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -14,12 +14,26 @@ import gc import json +from decimal import Decimal +from fractions import Fraction +from itertools import islice +import socket +import ssl +import struct import unittest from unittest import mock from unittest.mock import Mock +import numpy +import pytest + +from cassandra.cluster import Cluster from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, - DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH) + DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, + _non_negative_ms, _optional_ms, _reconnection_policy_report, + _required_ms, _socket_report) +from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, + ReconnectionPolicy) from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -42,28 +56,33 @@ def _build_report(self, cluster, is_scylla): return None -def reporter(cluster=None): +def reporter(test, **cluster_kwargs): """ - A reporter over `cluster`, defaulting to one whose configuration is never - read because nothing populates the report yet. + The reporter of a real Cluster, which stays alive for the test. - The cluster is kept alive for as long as the reporter is: it is held weakly, - so a temporary would be collected before the report is built and every test - here would silently exercise the cluster-is-gone path instead. + A stand-in cluster is no longer enough now that the report describes one: + the groups read real settings, and inventing them would keep a test passing + after one had been renamed. """ - cluster = cluster if cluster is not None else Mock() - r = DriverConfigReporter(cluster) - r._strong_cluster = cluster - return r + cluster = Cluster(**cluster_kwargs) + test.addCleanup(cluster.shutdown) + return cluster._driver_config_reporter + + +def report_text(test, **cluster_kwargs): + """The report of a real Cluster, as it goes on the wire.""" + cluster = Cluster(**cluster_kwargs) + test.addCleanup(cluster.shutdown) + return cluster._driver_config_reporter._build_report(cluster, is_scylla=True) class DriverConfigReporterTest(unittest.TestCase): def test_reports_the_schema_version(self): options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) - assert json.loads(options[DRIVER_CONFIG_OPTION]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + assert json.loads(options[DRIVER_CONFIG_OPTION])['version'] == DRIVER_CONFIG_SCHEMA_VERSION def test_report_is_compact_json(self): """ @@ -72,19 +91,20 @@ def test_report_is_compact_json(self): """ options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) - assert options[DRIVER_CONFIG_OPTION] == '{"version":%d}' % DRIVER_CONFIG_SCHEMA_VERSION + report = options[DRIVER_CONFIG_OPTION] + assert report == json.dumps(json.loads(report), separators=(',', ':')) def test_report_fits_within_the_length_limit(self): """ - Tripwire for when the actual configuration groups land: a report over the - limit is dropped by add_startup_options, so this would fail with a clear - message instead of the size assertion raising an unrelated KeyError. + A report over the limit is dropped by add_startup_options, so this fails + with a clear message instead of the size assertion raising an unrelated + KeyError. """ options = {} - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) assert DRIVER_CONFIG_OPTION in options, \ "the report was dropped, it must have exceeded the length limit" @@ -154,6 +174,650 @@ def test_other_options_are_left_alone(self): OversizedReporter().add_startup_options(options, is_scylla=True) MistypedReporter().add_startup_options(options, is_scylla=True) - reporter().add_startup_options(options, is_scylla=True) + reporter(self).add_startup_options(options, is_scylla=True) assert options['APPLICATION_NAME'] == 'app' + + +def connection_report(test, **cluster_kwargs): + """ + The ``connection`` group of the report a real Cluster produces. + + Built from a real Cluster rather than a stand-in: the group is a mapping + from this driver's settings onto the shared schema, so a test that invented + the settings would keep passing after one of them was renamed. + """ + return json.loads(report_text(test, **cluster_kwargs))['connection'] + + +class MillisecondConversionTest(unittest.TestCase): + """ + Durations are float seconds in this driver and integer milliseconds in the + schema, and the three fields differ in what they do at and below zero. + """ + + def test_optional_is_left_out_when_unset_or_disabled(self): + assert _optional_ms(None) is None + assert _optional_ms(0) is None + assert _optional_ms(-1) is None + + def test_optional_converts_seconds(self): + assert _optional_ms(5) == 5000 + assert _optional_ms(2.5) == 2500 + + def test_a_whole_millisecond_survives_the_conversion(self): + """ + Binary floating point often lands the product just under its integer -- + 1.005 seconds multiplies out to 1004.9999999999999 -- so truncating + loses a millisecond and describes a timeout nobody configured. Swept + rather than spot-checked: 372 of these used to come back low. + """ + assert 1.005 * 1000 != 1005 # the premise, in case it ever stops being true + assert _optional_ms(1.005) == 1005 + assert _non_negative_ms(1.005) == 1005 + + wrong = [ms for ms in range(1, 60001) if _optional_ms(ms / 1000) != ms] + assert wrong == [] + wrong = [ms for ms in range(1, 60001) if _non_negative_ms(ms / 1000) != ms] + assert wrong == [] + + def test_a_configured_duration_never_reports_as_zero(self): + """ + positiveInteger cannot express it, and a sub-millisecond timeout is + still a timeout: reporting zero would be a value the schema rejects. + """ + assert _optional_ms(0.0004) == 1 + assert _required_ms(0.0004) == 1 + + def test_required_falls_back_rather_than_being_left_out(self): + assert _required_ms(0) == 1 + assert _required_ms(-1) == 1 + assert _required_ms(None) == 1 + + def test_non_negative_never_truncates_a_wait_to_no_wait(self): + """ + Zero is not "very little" for these fields, it is the driver skipping + the wait: the schema reads it as "do not wait" / "immediately". A + configured sub-millisecond wait is one the driver really takes -- + _wait_for_schema_agreement bypasses agreement only at zero or less -- so + truncating it to zero would report the opposite. + """ + for seconds in (0.0004, 0.0005, 0.0009): + assert _non_negative_ms(seconds) == 1, seconds + + # And the two converters agree wherever both have an answer. + for seconds in (0.0004, 0.001, 2.5): + assert _non_negative_ms(seconds) == _optional_ms(seconds), seconds + + def test_non_negative_keeps_zero(self): + """ + Zero means "do not wait" or "reconnect immediately" for the fields that + take it, so it is a value rather than the absence of one. + """ + assert _non_negative_ms(0) == 0 + assert _non_negative_ms(10) == 10000 + assert _non_negative_ms(None) == 0 + assert _non_negative_ms(-5) == 0 + + +class ConnectionGroupTest(unittest.TestCase): + def test_defaults(self): + assert connection_report(self) == { + 'connect': {'timeout-ms': 5000}, + 'requests': {'in-flight': {'max': 32767}, 'orphaned': {'max': 24575}}, + 'pool': {'shard-aware': {'enabled': True}}, + 'socket': {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False}, + 'reconnection': {'policy': {'type': 'exponential', + 'base-ms': 1000, 'max-ms': 600000}}, + } + + def test_no_read_or_write_or_heartbeat_group(self): + """ + This driver has no socket read or write timeout, and the group the + schema reserves for heartbeat settings is empty in this version, so + idle_heartbeat_interval has nowhere to go. + """ + report = connection_report(self, idle_heartbeat_interval=7) + + for absent in ('read', 'write', 'heartbeat', 'node-preference'): + assert absent not in report + + def test_connect_timeout(self): + assert connection_report(self, connect_timeout=12)['connect'] == {'timeout-ms': 12000} + # positiveInteger, so a disabled timeout is an absent key rather than a + # zero the schema would reject. + assert connection_report(self, connect_timeout=0)['connect'] == {} + + def test_in_flight_is_the_admission_ceiling_not_the_stream_pool(self): + """ + Driven through the gate itself rather than asserted against a constant: + borrow_connection admits while `in_flight < max_request_id`, so the most + a connection ever carries is max_request_id, one short of the number of + stream ids it has. + """ + max_request_id = Cluster.connection_class.max_request_id + + in_flight = 0 + while in_flight < max_request_id: + in_flight += 1 + + assert connection_report(self)['requests']['in-flight']['max'] == in_flight + + def test_in_flight_matches_what_a_connection_will_allow(self): + """ + Reported off the connection class rather than hardcoded, since that is + what a connection derives its own limit from. + """ + report = connection_report(self) + max_request_id = Cluster.connection_class.max_request_id + + # The ceiling itself: borrow_connection admits only while in_flight is + # under max_request_id, so the stream id pool is one larger than the + # concurrency it permits. + assert report['requests']['in-flight']['max'] == max_request_id + # One below the threshold: the gate marks a connection at that count, + # so the most it is ever allowed to hold is one less. + assert report['requests']['orphaned']['max'] == \ + Cluster.connection_class.orphaned_threshold - 1 + + def test_orphaned_is_the_tolerated_count_not_the_replacement_trigger(self): + """ + Driven through the gate itself rather than asserted against the + attribute: ResponseFuture._on_timeout adds the orphaned id and then + tests `len(orphaned_request_ids) >= orphaned_threshold`, so a connection + holding that many is already marked for replacement. What the schema + asks for is the most it is allowed to hold, which is one less. + """ + threshold = Cluster.connection_class.orphaned_threshold + + orphans, marked_at = set(), None + for request_id in range(threshold + 2): + orphans.add(request_id) + if len(orphans) >= threshold and marked_at is None: + marked_at = len(orphans) + + assert marked_at == threshold + assert connection_report(self)['requests']['orphaned']['max'] == marked_at - 1 + + def test_shard_awareness(self): + assert connection_report(self)['pool'] == {'shard-aware': {'enabled': True}} + + for disabling in ({'disable': True}, {'disable_shardaware_port': True}): + report = connection_report(self, shard_aware_options=disabling) + assert report['pool'] == {'shard-aware': {'enabled': False}}, disabling + + +class SocketOptionsTest(unittest.TestCase): + OFF = {'tcp-no-delay': False, 'keep-alive': False, 'reuse-address': False} + + def test_unset_options_report_the_platform_default(self): + """ + The driver sets no socket options of its own, so an option absent from + sockopts is left wherever the operating system has it, which for a fresh + TCP socket is off. + """ + assert _socket_report(None) == self.OFF + assert _socket_report([]) == self.OFF + + def test_configured_flags(self): + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), + ]) + + assert report == {'tcp-no-delay': True, 'keep-alive': True, 'reuse-address': True} + + def test_an_option_of_any_integer_type_is_read(self): + """ + setsockopt takes anything with __index__, so a numpy integer sets an + option just as a builtin one does. Checked against the kernel, since the + claim is about what setsockopt accepts. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + for value in (numpy.int64(1), numpy.int64(0), True, 1, 0): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, value) + kernel = bool(sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY)) + + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, value)]) + + assert report['tcp-no-delay'] is kernel, value + + size = _socket_report( + [(socket.SOL_SOCKET, socket.SO_RCVBUF, numpy.int64(65536))])['receive-buffer'] + assert size == {'size-bytes': 65536} + assert type(size['size-bytes']) is int + + def test_a_flag_set_to_zero_is_off(self): + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)]) + + assert report['tcp-no-delay'] is False + + def test_the_last_setting_of_an_option_wins(self): + """ + As it does in the loop that applies them, where each setsockopt call + overwrites the one before. + """ + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 0), + ]) + + assert report['tcp-no-delay'] is False + + def test_buffer_sizes(self): + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, 65536), + (socket.SOL_SOCKET, socket.SO_SNDBUF, 32768), + ]) + + assert report['receive-buffer'] == {'size-bytes': 65536} + assert report['send-buffer'] == {'size-bytes': 32768} + + def test_buffer_sizes_are_left_out_when_not_a_positive_size(self): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_RCVBUF, 0)]) + + assert 'receive-buffer' not in report + + def test_linger(self): + """ + SO_LINGER is the one option whose value is a packed struct rather than + an integer, because that is what setsockopt takes. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 30)), + ]) + + assert report['linger'] == {'interval-s': 30} + + def test_linger_is_left_out_when_disabled_or_unreadable(self): + for value in (struct.pack('ii', 0, 30), b'short', 30, None): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert 'linger' not in report, value + + def test_a_flag_packed_as_a_buffer(self): + """ + setsockopt takes an integer option either as an int or as a packed + buffer, and the kernel honours both, so the report has to read both. A + packed buffer is non-empty bytes, so bool() alone calls every option + enabled -- including one packed to zero to turn it off, which is the + configuration this gets wrong in the worst direction. + """ + for packed, expected in ((struct.pack('i', 0), False), + (struct.pack('i', 1), True), + # Any width: the value reaches the kernel as + # raw bytes, so a zero is a zero regardless. + (struct.pack('q', 0), False), + (struct.pack('q', 1), True)): + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed)]) + assert report['tcp-no-delay'] is expected, packed + + def test_the_kernel_really_honours_a_packed_flag(self): + """ + The premise of the test above, read off a socket rather than assumed. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 0)) + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) == 0 + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 1)) + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + + def test_only_the_leading_int_of_a_buffer_is_read(self): + """ + setsockopt takes the C int at the front of the buffer and ignores what + follows, so reading the whole buffer as one wide integer answers for + bytes the option never had: pack('ii', 0, 1) leaves TCP_NODELAY off + while all eight bytes come to a large non-zero number. + + Checked against a real socket, since the claim is about what the kernel + does rather than about this module. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + for packed in (struct.pack('ii', 0, 1), struct.pack('ii', 1, 0), + struct.pack('i', 0), struct.pack('i', 1), + struct.pack('q', 1)): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed) + kernel = bool(sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY)) + + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, packed)]) + + assert report['tcp-no-delay'] is kernel, packed + + def test_a_buffer_too_short_for_an_int_is_skipped(self): + """ + setsockopt rejects it, so there is nothing to report for it. + """ + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, b'ab')]) + + assert report['tcp-no-delay'] is False + + def test_every_buffer_type_setsockopt_takes_is_read(self): + """ + memoryview among them, which the linger group used to drop. + """ + packed = struct.pack('ii', 1, 30) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, memoryview(packed)) + + for value in (packed, bytearray(packed), memoryview(packed)): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert report['linger'] == {'interval-s': 30}, type(value) + + for value in (memoryview(struct.pack('i', 1)), b'abc'): + report = _socket_report([(socket.SOL_SOCKET, socket.SO_LINGER, value)]) + assert 'linger' not in report, type(value) + + def test_a_buffer_size_packed_as_a_buffer(self): + """ + Same root cause, milder symptom: a packed size used to be dropped rather + than misread, so the option went unreported instead of wrong. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, struct.pack('i', 65536)), + (socket.SOL_SOCKET, socket.SO_SNDBUF, struct.pack('i', 0)), + ]) + + assert report['receive-buffer'] == {'size-bytes': 65536} + # Zero is not a size, whichever form it arrives in. + assert 'send-buffer' not in report + + def test_packed_and_plain_values_mix(self): + """ + Last one wins across both forms, as it does in the loop that applies + them. + """ + report = _socket_report([ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.IPPROTO_TCP, socket.TCP_NODELAY, struct.pack('i', 0)), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, struct.pack('i', 0)), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ]) + + assert report['tcp-no-delay'] is False + assert report['keep-alive'] is True + + def test_a_value_that_is_neither_reports_the_default(self): + """ + setsockopt would reject it at connect time; there is nothing to report + for it, and guessing enabled would be the same mistake as before. + """ + report = _socket_report([(socket.IPPROTO_TCP, socket.TCP_NODELAY, 'yes')]) + + assert report['tcp-no-delay'] is False + + def test_malformed_entries_are_skipped(self): + """ + setsockopt also takes a (level, name, None, optlen) form, and an entry + that is neither is the user's to get wrong when the connection applies + it, not this module's to fail on. + """ + report = _socket_report([ + (socket.SOL_SOCKET, socket.SO_RCVBUF, None, 4), + 'nonsense', + None, + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + ]) + + assert report['tcp-no-delay'] is True + + +class ReconnectionPolicyReportTest(unittest.TestCase): + def test_exponential(self): + report = _reconnection_policy_report(ExponentialReconnectionPolicy(2.0, 60.0)) + + assert report == {'type': 'exponential', 'base-ms': 2000, 'max-ms': 60000} + + def test_constant(self): + report = _reconnection_policy_report(ConstantReconnectionPolicy(1.5)) + + assert report == {'type': 'constant', 'delay-ms': 1500} + + def test_a_sub_millisecond_delay_is_not_an_immediate_one(self): + """ + A configured wait below a millisecond is still a wait, and the schema + reads a zero delay as "reconnect immediately", so the two must not + report alike. + """ + assert _reconnection_policy_report( + ConstantReconnectionPolicy(0.0004))['delay-ms'] == 1 + assert _reconnection_policy_report( + ConstantReconnectionPolicy(0))['delay-ms'] == 0 + + def test_a_constant_delay_of_zero_is_reported(self): + """ + nonNegativeInteger here: zero means reconnect immediately, which is a + setting rather than the absence of one. + """ + report = _reconnection_policy_report(ConstantReconnectionPolicy(0)) + + assert report['delay-ms'] == 0 + + def test_max_attempts(self): + report = _reconnection_policy_report(ConstantReconnectionPolicy(1, max_attempts=5)) + + assert report['max-attempts'] == 5 + + def test_unlimited_attempts_are_left_out(self): + """ + None means unlimited to both policies, and so does zero to the constant + one: its `if self.max_attempts` is falsy for zero and falls through to + an unbounded repeat. + """ + for policy in (ConstantReconnectionPolicy(1, max_attempts=None), + ConstantReconnectionPolicy(1, max_attempts=0), + ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=None)): + assert 'max-attempts' not in _reconnection_policy_report(policy) + + def test_an_exponential_policy_that_never_attempts_is_reported_as_no_policy(self): + """ + The two policies read a max_attempts of zero in opposite ways. The + exponential one drives + `while max_attempts is None or i < max_attempts`, so zero yields nothing + and the driver never reconnects -- the schema's null arm. Reporting it + as an exponential policy with max-attempts left out would say the + opposite, since absent reads as unlimited. + """ + assert list(ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0).new_schedule()) == [] + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0)) is None + + # The constant policy really is unlimited at zero, so it keeps its arm. + assert next(ConstantReconnectionPolicy(1, max_attempts=0).new_schedule()) == 1 + assert _reconnection_policy_report( + ConstantReconnectionPolicy(1, max_attempts=0))['type'] == 'constant' + + def test_a_policy_that_never_reconnects_reports_null(self): + """ + The null arm reached from a real configuration rather than from no + policy at all. That it survives schema validation is asserted where the + report is a whole conformant document -- see ReportConformsToTheSchemaTest + -- which it is not yet at this point in the series. + """ + report = json.loads(report_text( + self, reconnection_policy=ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0))) + + assert report['connection']['reconnection']['policy'] is None + + def test_an_exponential_policy_with_no_base_delay_is_constant(self): + """ + The schedule is base_delay * 2 ** i, so a base of zero stays zero + however high max_delay is: the driver reconnects immediately, every + time. Reporting the exponential arm would claim a delay that grows, and + its base is a positiveInteger that cannot hold the zero anyway. + """ + schedule = list(islice(ExponentialReconnectionPolicy(0, 60.0).new_schedule(), 6)) + assert schedule == [0, 0, 0, 0, 0, 0] + + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(0, 60.0)) == {'type': 'constant', 'delay-ms': 0} + + def test_never_reconnecting_still_wins_over_a_zero_base(self): + """ + Zero attempts means the schedule is empty, which the null arm says and + a constant delay of zero would contradict. + """ + assert _reconnection_policy_report( + ExponentialReconnectionPolicy(0, 60.0, max_attempts=0)) is None + + def test_a_fractional_exponential_limit_is_finite(self): + """ + new_schedule loops `while max_attempts is None or i < max_attempts`, + which compares against a fraction as readily as an integer: 1.5 admits + an i of 0 and of 1, so two attempts are made. Leaving the key out would + report that as unlimited. + """ + for limit, attempts in ((0.5, 1), (1.5, 2), (2.5, 3)): + policy = ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=limit) + assert len(list(policy.new_schedule())) == attempts, limit + + assert _reconnection_policy_report(policy)['max-attempts'] == attempts, limit + + def test_a_fractional_constant_limit_has_no_count_to_report(self): + """ + The two policies read the same attribute with different code and + disagree about the same value, which is why the limit is read per + policy. This one hands max_attempts to itertools.repeat, which takes + only an integer, so a fraction is a policy that raises when it + reconnects rather than one that counts. + """ + policy = ConstantReconnectionPolicy(1.0, max_attempts=1.5) + with pytest.raises(TypeError): + policy.new_schedule() + + assert 'max-attempts' not in _reconnection_policy_report(policy) + + def test_a_limit_of_any_countable_type_is_reported(self): + """ + The exponential schedule compares `i < max_attempts`, which works + against anything an integer can be compared with, so a limit need not be + a builtin number to bound it. Reporting only int and float left these + finite schedules described as unlimited. + """ + for limit, attempts in ((Decimal('2'), 2), (Fraction(3, 2), 2), (True, 1)): + policy = ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=limit) + assert len(list(policy.new_schedule())) == attempts, limit + + assert _reconnection_policy_report(policy)['max-attempts'] == attempts, limit + + def test_a_constant_limit_is_whatever_repeat_accepts(self): + """ + new_schedule hands max_attempts to itertools.repeat, and what that + accepts is not the same on every interpreter: CPython wants __index__ + and rejects a Decimal, PyPy takes one and counts it. So the report is + checked against the schedule the policy actually produces rather than + against either interpreter's rule -- a driver on PyPy really does + reconnect twice where the same configuration raises on CPython. + + A bool is one repeat either way, reported as the number 1, since the + schema wants an integer and JSON true is not one. + """ + report = _reconnection_policy_report( + ConstantReconnectionPolicy(1.0, max_attempts=True)) + assert report['max-attempts'] == 1 + assert 'true' not in json.dumps(report) + + for limit in (Decimal('2'), Fraction(3, 2), 3): + policy = ConstantReconnectionPolicy(1.0, max_attempts=limit) + try: + attempts = len(list(policy.new_schedule())) + except TypeError: + # The policy raises when it reconnects: no count to report. + attempts = None + + report = _reconnection_policy_report( + ConstantReconnectionPolicy(1.0, max_attempts=limit)) + + if attempts is None: + assert 'max-attempts' not in report, limit + else: + assert report['max-attempts'] == attempts, limit + + def test_a_limit_that_makes_an_empty_schedule_never_reconnects(self): + """ + A negative limit is truthy, so new_schedule passes it to repeat and gets + an empty schedule back -- on every interpreter. The driver never + reconnects, which is the null arm; leaving max-attempts out would say + unlimited. Only reachable by assignment, since the constructor rejects a + negative. + """ + policy = ConstantReconnectionPolicy(1.0, max_attempts=1) + policy.max_attempts = -5 + + assert list(policy.new_schedule()) == [] + assert _reconnection_policy_report(policy) is None + + def test_reported_counts_are_builtin_ints(self): + """ + Whatever type the limit arrived as, what goes on the wire is a JSON + number. + """ + for policy in (ExponentialReconnectionPolicy(1.0, 60.0, max_attempts=Decimal('2')), + ConstantReconnectionPolicy(1.0, max_attempts=True)): + attempts = _reconnection_policy_report(policy)['max-attempts'] + assert type(attempts) is int, policy + + def test_no_policy(self): + assert _reconnection_policy_report(None) is None + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveReconnectionPolicy(ReconnectionPolicy): + def __init__(self): + self.password = 'hunter2' + + def new_schedule(self): + return iter(()) + + report = _reconnection_policy_report(SecretiveReconnectionPolicy()) + + assert report == {'type': 'custom', 'name': 'SecretiveReconnectionPolicy'} + + def test_a_subclass_of_a_built_in_is_custom(self): + """ + Dispatch is on the exact type: a subclass is a policy the driver knows + nothing about, and describing it as its parent would put the parent's + parameters against behaviour it does not have. + """ + class Tweaked(ExponentialReconnectionPolicy): + pass + + report = _reconnection_policy_report(Tweaked(1.0, 2.0)) + + assert report == {'type': 'custom', 'name': 'Tweaked'} + + +class TlsReportTest(unittest.TestCase): + def report(self, **cluster_kwargs): + return connection_report(self, **cluster_kwargs).get('tls') + + def test_absent_when_tls_is_not_configured(self): + assert self.report() is None + + def test_hostname_verification_from_an_ssl_context(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + assert self.report(ssl_context=context) == {'hostname-verification': False} + + verifying = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + assert verifying.check_hostname + assert self.report(ssl_context=verifying) == {'hostname-verification': True} + + def test_hostname_verification_from_ssl_options(self): + """ + Options on their own are turned into a context by the connection, which + reads the same key this does. + """ + assert self.report(ssl_options={'check_hostname': True}) == {'hostname-verification': True} + assert self.report(ssl_options={'ca_certs': '/dev/null'}) == {'hostname-verification': False} + + def test_no_credentials_are_reported(self): + """ + The schema is explicit that this group carries booleans only, never + credentials, keys or host lists. + """ + report = self.report(ssl_options={'check_hostname': True, + 'keyfile': '/secret/key.pem', + 'certfile': '/secret/cert.pem', + 'ca_certs': '/secret/ca.pem'}) + + assert report == {'hostname-verification': True} From f86b501dcfd9e2e359e52fc6d877fb96494aa124 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 6/9] DRIVER-379: Report the control-plane group The timeouts on the driver's own queries: the ones it runs to discover the cluster rather than on behalf of the application. The two system-query timeouts are different things, which is why the schema has both. client-side-ms is how long the driver waits for a reply. server-side-ms is a limit the server enforces, which this driver applies by appending USING TIMEOUT -- a ScyllaDB extension, so it is reported only against a ScyllaDB node, mirroring ControlConnection._try_connect: the report describes what the driver will do, not only what it was configured to do. Schema agreement stays in the report at zero, which says the driver does not wait for agreement -- a setting rather than the absence of one. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/driver_config.py | 63 +++++++++++++++ tests/unit/test_driver_config.py | 131 +++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 782cc12f85..5ea2413bb3 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -18,6 +18,7 @@ incident can inspect the settings of a client without access to its host. """ +import datetime import json import logging import math @@ -97,6 +98,29 @@ def _milliseconds(seconds): return int(round(seconds * 1000)) +def _server_side_timeout_ms(seconds): + """ + The server-side limit the driver will actually impose, in milliseconds, or + ``None`` when it will impose none. + + Converted the way :func:`cassandra.util.maybe_add_timeout_to_query` converts + it rather than the way every other duration here is converted, because that + builder is what the server ends up being told: it divides a timedelta into + whole milliseconds, truncating, and appends no ``USING TIMEOUT`` at all when + that comes to zero. Rounding up, or promoting a sub-millisecond value to one + as the other converters do, would report a limit the server is never given + -- 0.0016 seconds is sent as 1ms, and 0.0006 seconds is not sent at all. + + A negative value is left out too. The builder does append it, but the clause + is malformed and the server rejects it, and the schema has no way to carry a + negative anyway. + """ + if seconds is None: + return None + ms = int(datetime.timedelta(seconds=seconds) / datetime.timedelta(milliseconds=1)) + return ms if ms > 0 else None + + def _optional_ms(seconds): """ Milliseconds for a schema field of type ``positiveInteger``, or ``None`` @@ -480,6 +504,7 @@ def _populate_report(self, report, cluster, is_scylla): Adds the configuration groups themselves to the report. """ report['connection'] = self._connection_report(cluster) + report['control-plane'] = self._control_plane_report(cluster, is_scylla) def _connection_report(self, cluster): """ @@ -557,6 +582,44 @@ def _connection_report(self, cluster): report['tls'] = tls return report + def _control_plane_report(self, cluster, is_scylla): + """ + The ``control-plane`` group: the timeouts on the driver's own queries, + the ones it runs to discover the cluster rather than on behalf of the + application. + + The two system-query timeouts are different things, which is why the + schema has both. The client-side one is how long the driver waits for a + reply; the server-side one is a limit the server enforces, which this + driver applies by appending ``USING TIMEOUT`` to the query. + """ + timeout = {} + + client_side_ms = _optional_ms(cluster.control_connection_timeout) + if client_side_ms is not None: + timeout['client-side-ms'] = client_side_ms + + # USING TIMEOUT is a ScyllaDB extension, so against anything else the + # driver does not append it and there is no server-side limit to report: + # ControlConnection._try_connect drops metadata_request_timeout on a + # connection with no sharding info, and this reports what the driver + # will do rather than only what it was configured to do. A configured + # zero means the same thing, letting the server's own default apply. + if is_scylla: + server_side_ms = _server_side_timeout_ms(cluster.metadata_request_timeout) + if server_side_ms is not None: + timeout['server-side-ms'] = server_side_ms + + return { + 'queries': {'system': {'timeout': timeout}}, + 'schema': { + # nonNegativeInteger and required: zero means the driver does + # not wait for schema agreement at all, which is a setting + # rather than the absence of one. + 'agreement': {'timeout-ms': _non_negative_ms(cluster.max_schema_agreement_wait)}, + }, + } + def _tls_report(self, cluster): """ The ``connection.tls`` group, or ``None`` when TLS is not configured. diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index aadde0d586..3bb921973d 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import gc import json from decimal import Decimal @@ -32,6 +33,7 @@ DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, _non_negative_ms, _optional_ms, _reconnection_policy_report, _required_ms, _socket_report) +from cassandra.util import maybe_add_timeout_to_query from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, ReconnectionPolicy) from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -821,3 +823,132 @@ def test_no_credentials_are_reported(self): 'ca_certs': '/secret/ca.pem'}) assert report == {'hostname-verification': True} + + +def control_plane_report(test, is_scylla=True, **cluster_kwargs): + cluster = Cluster(**cluster_kwargs) + test.addCleanup(cluster.shutdown) + report = cluster._driver_config_reporter._build_report(cluster, is_scylla=is_scylla) + return json.loads(report)['control-plane'] + + +class ControlPlaneGroupTest(unittest.TestCase): + def test_defaults(self): + assert control_plane_report(self) == { + 'queries': {'system': {'timeout': {'client-side-ms': 2000, + 'server-side-ms': 2000}}}, + 'schema': {'agreement': {'timeout-ms': 10000}}, + } + + def test_client_side_timeout(self): + report = control_plane_report(self, control_connection_timeout=4.5) + + assert report['queries']['system']['timeout']['client-side-ms'] == 4500 + + def test_client_side_timeout_is_left_out_when_disabled(self): + report = control_plane_report(self, control_connection_timeout=0) + + assert 'client-side-ms' not in report['queries']['system']['timeout'] + + def test_server_side_timeout_defaults_to_the_client_side_one(self): + """ + Which is what the Cluster does with it when it is not given one. + """ + report = control_plane_report(self, control_connection_timeout=3) + + assert report['queries']['system']['timeout'] == {'client-side-ms': 3000, + 'server-side-ms': 3000} + + def test_server_side_timeout(self): + report = control_plane_report(self, metadata_request_timeout=8) + + assert report['queries']['system']['timeout']['server-side-ms'] == 8000 + + def test_the_server_side_timeout_is_the_clause_the_driver_sends(self): + """ + This one value does not go through the usual conversion. What reaches + the server is whatever maybe_add_timeout_to_query builds, and that + divides a timedelta into whole milliseconds, truncating, and appends no + clause at all when it comes to zero. Rounding up or promoting a + sub-millisecond value -- as every other duration here is -- would report + a limit the server is never given. + + Checked against the builder rather than against numbers written out + here, so the two cannot drift apart. + """ + for seconds in (0.0004, 0.0006, 0.001, 0.0016, 0.002, 0.0025, 1.005, 2, 0): + statement = maybe_add_timeout_to_query( + 'SELECT 1', datetime.timedelta(seconds=seconds)) + sent = (int(statement.split('USING TIMEOUT ')[1][:-2]) + if 'USING TIMEOUT' in statement else None) + + timeout = control_plane_report( + self, metadata_request_timeout=seconds)['queries']['system']['timeout'] + + assert timeout.get('server-side-ms') == sent, seconds + + def test_a_negative_server_side_timeout_is_left_out(self): + """ + The builder does append it, but the clause is malformed and the server + rejects it, and server-side-ms is a positiveInteger with nowhere to put + a negative. + """ + timeout = control_plane_report( + self, metadata_request_timeout=-0.005)['queries']['system']['timeout'] + + assert 'server-side-ms' not in timeout + + def test_no_server_side_timeout_against_a_non_scylla_node(self): + """ + USING TIMEOUT is a ScyllaDB extension, so elsewhere the driver does not + append it and there is no server-side limit to report. The report + describes what the driver will do, not only what it was configured to. + """ + report = control_plane_report(self, is_scylla=False, metadata_request_timeout=8) + + assert 'server-side-ms' not in report['queries']['system']['timeout'] + # The client-side timeout is the driver's own and applies regardless. + assert 'client-side-ms' in report['queries']['system']['timeout'] + + def test_no_server_side_timeout_when_disabled(self): + """ + Zero means the driver appends no USING TIMEOUT and the server's own + default applies, so there is no limit of the driver's to report. + """ + report = control_plane_report(self, metadata_request_timeout=0) + + assert 'server-side-ms' not in report['queries']['system']['timeout'] + + def test_both_timeouts_can_be_absent(self): + """ + The group stays, since the schema requires it; it is the timeouts inside + that are optional. + """ + report = control_plane_report(self, control_connection_timeout=0, + metadata_request_timeout=0) + + assert report['queries'] == {'system': {'timeout': {}}} + + def test_schema_agreement_timeout(self): + report = control_plane_report(self, max_schema_agreement_wait=25) + + assert report['schema']['agreement']['timeout-ms'] == 25000 + + def test_a_sub_millisecond_wait_is_still_a_wait(self): + """ + _wait_for_schema_agreement bypasses agreement only for a timeout of zero + or less, so a sub-millisecond wait is one the driver really takes. + Truncating it to zero would report the bypass instead. + """ + report = control_plane_report(self, max_schema_agreement_wait=0.0004) + + assert report['schema']['agreement']['timeout-ms'] == 1 + + def test_not_waiting_for_schema_agreement_is_a_value(self): + """ + nonNegativeInteger: zero says the driver does not wait, which is a + setting rather than the absence of one, so the key stays. + """ + report = control_plane_report(self, max_schema_agreement_wait=0) + + assert report['schema']['agreement']['timeout-ms'] == 0 From df625674fd4468316bcc5ffa4c3f97a299823ea0 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 7/9] DRIVER-379: Report the query group What the driver does with a statement that overrides none of it: the defaults it applies, how a failure is retried, which node it goes to, and whether a slow one is raced. Reported from the default execution profile. The schema has one query group and this driver has as many profiles as the application defines, so the one that describes the session is the one a statement gets when it names none -- which covers legacy configuration too, since Cluster folds a load_balancing_policy or default_retry_policy given to its constructor into that same profile. Other profiles cannot be described under this schema version; worth raising for v2. The built-in retry policies all subclass RetryPolicy, so dispatch is on the exact type: isinstance would report every one of them as the standard policy. ExponentialBackoffRetryPolicy has no arm of its own, but it retries what the standard policy retries and adds a growing delay, which is what the schema's backoff describes. Only TokenAwarePolicy maps onto the schema's built-in load balancing arm; the round-robin and wrapper policies report as custom. The datacenter preference is reported either way, found wherever in the policy chain it is set: it says where requests go, not which policy sends them, and a bare DCAwareRoundRobinPolicy -- what the driver falls back to without the murmur3 extension -- pins the client just as firmly as a token-aware one wrapping it. Three of the defaults are not the profile's. Paging and client timestamps are Session settings and no Session exists when the control connection reports, so what is described is the default every Session starts with. Idempotence has no configurable default at all, so it is always false. A custom timestamp generator leaves client-timestamps out entirely: it may return None for some requests, leaving the coordinator to assign the timestamp after all, and there is no telling from here which it will do. With this group the report is conformant, which the tests now assert against the vendored schema -- including one that gives a custom policy a password and asserts it appears nowhere in the report. Co-Authored-By: Claude Opus 5 (1M context) --- cassandra/driver_config.py | 685 ++++++++++++++++- tests/unit/test_driver_config.py | 1175 +++++++++++++++++++++++++++++- 2 files changed, 1842 insertions(+), 18 deletions(-) diff --git a/cassandra/driver_config.py b/cassandra/driver_config.py index 5ea2413bb3..b6b8fa2f4d 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -26,10 +26,22 @@ import socket import struct import weakref +from collections import namedtuple from itertools import repeat +from cassandra import ConsistencyLevel from cassandra.policies import (ConstantReconnectionPolicy, - ExponentialReconnectionPolicy) + ConstantSpeculativeExecutionPolicy, + DCAwareRoundRobinPolicy, + DowngradingConsistencyRetryPolicy, + ExponentialBackoffRetryPolicy, + ExponentialReconnectionPolicy, + FallthroughRetryPolicy, NeverRetryPolicy, + NoSpeculativeExecutionPolicy, + DefaultLoadBalancingPolicy, + RackAwareRoundRobinPolicy, RetryPolicy, + RoundRobinPolicy, TokenAwarePolicy) +from cassandra.timestamps import MonotonicTimestampGenerator log = logging.getLogger(__name__) @@ -167,6 +179,32 @@ def _non_negative_ms(seconds): return ms +def _consistency_name(level, setting): + """ + The schema's name for a consistency level. + + The schema takes the name and this driver holds the wire integer, and its + enum covers every level the driver defines, so any level that came from + :class:`~.ConsistencyLevel` maps. One that did not is not a level the driver + can use either -- ``None`` fails to pack into a request at all, and an + unknown integer is rejected by the server -- so there is nothing truthful to + report for it, and a report that named one anyway would tell an operator + that a client which cannot execute a query is querying at that level. + + Raising drops the whole report, which is the right outcome: `consistency` is + a required key, so no conformant document describes such a configuration. + The message names the setting, since the alternative is a bare KeyError + under a generic "unable to build the report" warning. + """ + try: + return ConsistencyLevel.value_to_name[level] + except KeyError: + raise ValueError( + "%s is %r, which is not a consistency level this driver defines; " + "the configuration report describes the consistency a client uses " + "and cannot describe one it cannot use" % (setting, level)) from None + + _SOCKET_FLAGS = ( ('tcp-no-delay', socket.IPPROTO_TCP, socket.TCP_NODELAY), ('keep-alive', socket.SOL_SOCKET, socket.SO_KEEPALIVE), @@ -286,22 +324,42 @@ def _socket_report(sockopts): return report +def _integer_ceiling(limit): + """ + `limit` rounded up to a builtin ``int``, or ``None`` when no integer can + express it. + + ``math.ceil`` rather than a check against particular numeric types: a limit + is compared against a counter, and anything that can say what its ceiling is + can be counted against one. The result is coerced to a builtin ``int`` so + that no other numeric type reaches the report, where the schema wants an + integer, and so that a limit of ``True`` does not travel on as JSON true + where a number belongs. + + ``None`` is for the limits arithmetic cannot name: ``float('inf')``, which is + how an application spells "without limit" and which the policies really do + accept, ``nan``, and anything that is not a number at all. Every caller has + its own way of saying that a limit is not one the report can carry, and none + of them may raise -- this runs while a connection is being established, and + one unnameable limit must not cost the report every other group it would + have carried. + """ + try: + return int(math.ceil(limit)) + except (TypeError, ValueError, OverflowError): + return None + + def _attempt_ceiling(limit): """ How many attempts a ``while i < limit`` loop makes, or ``None`` when `limit` - does not bound one. - - ``math.ceil`` rather than a check against particular numeric types: the loop - compares against anything an integer can be compared with, and this counts - anything that can say what its ceiling is. The result is coerced to a builtin - ``int`` so that no other numeric type reaches the report, where the schema - wants an integer. + does not bound one -- because it admits no attempt at all, or because it is + not a limit any integer can express. """ if limit is None: return None - try: - attempts = int(math.ceil(limit)) - except (TypeError, ValueError, OverflowError): + attempts = _integer_ceiling(limit) + if attempts is None: return None return attempts if attempts > 0 else None @@ -423,6 +481,471 @@ def _custom_policy_report(policy): return {'type': 'custom', 'name': type(policy).__name__} +_RETRY_POLICY_TYPES = { + # Exact types, not a base class: every one of the others below is a subclass + # of RetryPolicy, so isinstance would report all of them as the first entry. + RetryPolicy: 'standard-error-aware', + FallthroughRetryPolicy: 'fallthrough', + NeverRetryPolicy: 'never', + DowngradingConsistencyRetryPolicy: 'downgrading-consistency', +} + + +def _retry_report(policy, setting): + """ + The ``query.retry`` group: the policy, and the delay between attempts where + the policy has one. + + A policy of ``None`` is not the fallthrough it looks like. That arm means + the driver rethrows the original error to the caller untouched, and what + actually happens is that ResponseFuture calls ``on_request_error`` on it + and raises AttributeError -- losing the original error rather than passing + it on. Naming it fallthrough would describe a working configuration where + there is a broken one, and `policy` is a required key, so there is no + conformant document to be had either. ExecutionProfile replaces a None its + constructor is given, but both it and Cluster.default_retry_policy stay + writable and unvalidated, so this is reachable by assignment. + """ + if policy is None: + raise ValueError( + "%s is None, which is not a retry policy the driver can use: a " + "request error raises AttributeError on it rather than being " + "retried or passed on, and the configuration report describes what " + "a client does" % (setting,)) + + policy_type = _RETRY_POLICY_TYPES.get(type(policy)) + if policy_type is not None: + return {'policy': {'type': policy_type}} + + if type(policy) is ExponentialBackoffRetryPolicy: + # It retries the same errors as the standard policy and adds a growing + # delay between attempts, which is what the schema's backoff describes, + # so it is that policy with a backoff rather than a type of its own. + report = {'policy': {'type': 'standard-error-aware'}} + # Every on_* method gives up once retry_num reaches max_num_retries, and + # the comparison is `<`, so a fractional limit permits the ceiling: 0.5 + # allows one retry. Truncating would report that as no retries at all, + # which is what the schema reads a zero as. The attribute is typed float, + # so fractions are expected -- and so is float('inf'), which is how an + # application says "retry until the request runs out of time". No integer + # names that one, and the key is absent when no explicit limit is + # configured, which is the closest true thing the schema can say about + # it. A negative limit retries nothing, which is what a zero says. + max_retries = _integer_ceiling(policy.max_num_retries) + if max_retries is not None: + report['policy']['max-retries'] = max(0, max_retries) + # Only when there is a delay to describe. _calculate_backoff is + # min(max_interval, min_interval * 2 ** attempt) plus jitter scaled by + # min_interval, so a min_interval of zero is zero at every attempt + # whatever max_interval says. The schema leaves backoff out for exactly + # that -- "absent when there is no delay between attempts" -- and every + # delay it does carry must be greater than zero. + if policy.min_interval > 0: + # The initial delay is min(max_interval, min_interval), not + # min_interval: _calculate_backoff caps the whole curve at + # max_interval, and the policy does not check that the two were + # given the right way round. Reporting min_interval would claim a + # first delay the policy never waits whenever max_interval is the + # smaller. Taking the minimum also keeps the schema's requirement + # that max-ms be at least base-ms true by construction. + base_ms = _required_ms(min(policy.min_interval, policy.max_interval)) + report['backoff'] = {'type': 'exponential', + 'base-ms': base_ms, + 'max-ms': _required_ms(policy.max_interval)} + return report + + return {'policy': _custom_policy_report(policy)} + + +_MAX_POLICY_CHAIN = 1024 +""" +Backstop on how far to follow ``_child_policy`` looking for the policy that +holds the location preference. + +The walk stops on its own once it reaches a policy it has already seen, which is +what a chain looping back on itself does, so this is not what ends an ordinary +walk. It is here for the one case identity cannot catch: a ``_child_policy`` +implemented as a property that manufactures a new object on each access, where +every step looks like somewhere new. This runs while a connection is being +established, and a walk that never ends would hang the handshake -- the one +thing this module must never do. + +Set far above any chain an application would build, since stopping early is not +free: it reports no location preference at all, which reads as a client pinned +to nothing rather than one whose preference sits deeper than the walk went. The +deepest chain in :mod:`cassandra.policies` is three. +""" + + +_DESCRIBABLE_LOAD_BALANCING_POLICIES = ( + TokenAwarePolicy, + DCAwareRoundRobinPolicy, + RackAwareRoundRobinPolicy, + RoundRobinPolicy, + # Delegates every decision to its child bar one: it puts a query's + # target_host first when the statement sets one, which is a per-request + # choice rather than a property of the configuration this describes. + DefaultLoadBalancingPolicy, +) +"""Policies whose routing the token-aware flags can describe. + +Everything else makes the chain undescribable, however ordinary the policy +wrapping it. WhiteListRoundRobinPolicy confines routing to a fixed host list and +HostFilterPolicy to whatever an application-supplied predicate admits; neither +has anywhere to go in the built-in arm, so reporting that arm would assert +plain token-aware routing and say nothing of the restriction. + +Exact types, as everywhere else here: a subclass is a policy this module knows +nothing about, and WhiteListRoundRobinPolicy -- a RoundRobinPolicy subclass that +is emphatically not one -- is why that matters. +""" + + +def _policy_chain(policy): + """ + Each policy from `policy` down through ``_child_policy``. + + Stops on reaching a policy it has already seen, which is what a chain + looping back on itself does. Identity rather than equality, since a custom + policy is free to define __eq__ and compare equal to a different policy, or + to define it without __hash__ and not go into a set at all. Each policy is + held on to as well, so that its id cannot be reused by one created later in + the walk and read as a loop that is not there. + """ + seen, pinned = set(), [] + for _ in range(_MAX_POLICY_CHAIN): + if policy is None or id(policy) in seen: + return + yield policy + seen.add(id(policy)) + pinned.append(policy) + policy = getattr(policy, '_child_policy', None) + + +_PolicyChainSurvey = namedtuple('_PolicyChainSurvey', 'located token_aware describable') + + +def _survey_policy_chain(policy): + """ + Everything the load balancing group needs to know about a chain, from a + single walk of it: the policy carrying the location preference, the + token-aware policy, and whether every policy in it is one this module can + account for. + + One walk rather than one per question, for two reasons. + + It bounds the work. The walk is capped at :const:`_MAX_POLICY_CHAIN`, and the + case that cap exists for -- a ``_child_policy`` that manufactures a new + object on each access -- costs that many policy objects every time the chain + is walked, while a connection is being established. + + And it makes the answers describe the same chain. A ``_child_policy`` that + returns something different on each access hands a different chain to each + walk, so separate walks disagree: one finds a token-aware policy where the + next finds none. The report would then combine a preference found in one + chain with an arm decided from another. + + The token-aware policy is looked for anywhere in the chain rather than only + at the top, since a transparent wrapper above it does not stop the routing + being token aware. + """ + located = token_aware = None + describable = True + for link in _policy_chain(policy): + kind = type(link) + if token_aware is None and kind is TokenAwarePolicy: + token_aware = link + if located is None and kind in (DCAwareRoundRobinPolicy, RackAwareRoundRobinPolicy): + located = link + if kind not in _DESCRIBABLE_LOAD_BALANCING_POLICIES: + # The built-in arm's flags describe the routing of the chain, not of + # the policy at the top of it, so they can only be filled in when + # every policy in it is one this module knows. A chain reaching an + # application-supplied policy is reported as custom even with a + # driver policy wrapping it: the flags would otherwise assert + # something about query plans this code cannot see. + describable = False + return _PolicyChainSurvey(located, token_aware, describable) + + +def _location_policy(policy): + """ + The policy in the chain that carries the datacenter preference, or ``None`` + when nothing in it does. + + Under this schema the preference belongs to the session rather than to the + policy that happens to hold it, so it has to be found wherever the + application put it. A bare :class:`~.DCAwareRoundRobinPolicy` -- which is + what :func:`cassandra.cluster.default_lbp_factory` returns without the + murmur3 extension -- pins the driver to a datacenter just as firmly as a + token-aware policy wrapping one, and an operator reading the report cannot + tell that from the type name. + + Wrappers are followed through ``_child_policy``, which every one of them in + :mod:`cassandra.policies` uses. The report takes this from + :func:`_survey_policy_chain`, which answers it along with everything else it + needs from the one walk. + """ + return _survey_policy_chain(policy).located + + +def _node_location_preference_report(policy): + """ + The ``node-preference`` value describing which datacenter, and possibly + which rack, the driver prefers. + + Sourced from the load balancing policy, which is where this driver keeps it; + the schema asks for it here in that case rather than in a group of its own. + Takes what :func:`_location_policy` found, which is ``None`` when the chain + holds no location-aware policy at all. + """ + if type(policy) is DCAwareRoundRobinPolicy: + if policy._local_dc_explicit: + return {'type': 'dc', 'local-dc': policy.local_dc} + # Inferred from the first host to come up, and not necessarily known + # yet: the schema allows local-dc to be absent until it is. + report = {'type': 'dc-auto'} + if policy.local_dc: + report['local-dc'] = policy.local_dc + return report + + if type(policy) is RackAwareRoundRobinPolicy: + # Both are mandatory constructor arguments and are never reassigned, so + # they are configured rather than inferred whenever they are set at all. + if policy.local_dc and policy.local_rack: + return {'type': 'rack', 'local-dc': policy.local_dc, + 'local-rack': policy.local_rack} + if policy.local_dc: + return {'type': 'dc', 'local-dc': policy.local_dc} + + return None + + +def _falls_back_to_non_preferred_nodes(located, node_preference): + """ + Whether a request may reach a node outside the reported ``node-preference``. + + Defined against what was reported rather than against the policy type, + because that is how the schema defines it, and because a rack-aware policy + whose rack is unset reports a datacenter preference and has to be judged as + one. + + A rack preference always allows it. ``RackAwareRoundRobinPolicy``'s query + plan yields the local datacenter's other racks straight after the local-rack + tier, unconditionally -- ``used_hosts_per_remote_dc`` gates only the remote + datacenters below that -- so a request routinely reaches a node the reported + preference excludes. ``used_hosts_per_remote_dc`` of zero would otherwise + report the opposite. The schema's single boolean cannot say "leaves the rack + but not the datacenter", and of the two answers this is the true one. + + Only ``rack``, and not the schema's ``rack-auto``: + :func:`_node_location_preference_report` never reports the latter, because + ``RackAwareRoundRobinPolicy`` takes both the datacenter and the rack as + mandatory constructor arguments and never infers either. Testing for it here + would read as though this driver produces it somewhere. + + A datacenter preference allows it only once the policy is told how many + remote hosts to use, since both datacenter-aware policies ignore them + entirely until then. + + No preference at all does not, and not because such a chain keeps requests + anywhere -- a round-robin policy treats every host as local and will happily + reach a remote datacenter. It reports false because it declares no + preference for a request to fall outside of, and no node-preference is + reported for it either, which is what this flag is defined against. The + other ScyllaDB drivers do not all answer this the same way, so it is a + deliberate choice rather than the only reading. + """ + if node_preference is None: + return False + if node_preference['type'] == 'rack': + return True + return bool(getattr(located, 'used_hosts_per_remote_dc', 0)) + + +def _load_balancing_report(policy): + """ + The ``query.load-balancing`` group. + + The built-in ``token-aware`` arm carries flags describing where a request may + go, so it is claimed only when the chain holds a token-aware policy *and* + every policy in it is one this module can account for: see + :func:`_survey_policy_chain`. A transparent + wrapper above the token-aware policy does not disqualify the chain, since it + does not change where requests go; a policy whose routing this module cannot + see does, however ordinary the policy wrapping it. Everything else is a + policy the shared vocabulary has no terms for, and is reported by name -- + the plain round-robin policies among them, built in to this driver but not + token-aware. + + The datacenter preference is reported either way. It is a sibling of the + policy in the schema rather than a property of the built-in arm, and a + policy this module has no name for still pins the driver somewhere the + operator has to be able to see: see :func:`_location_policy`. + """ + survey = _survey_policy_chain(policy) + located = survey.located + # Built first: the fallback flag below is defined against what this reports. + node_preference = _node_location_preference_report(located) + + token_aware = survey.token_aware + if token_aware is not None and survey.describable: + report = { + 'policy': { + 'type': 'token-aware', + # Replicas are yielded in a random order unless that is turned + # off, in which case they keep the order the replica set has. + 'load-distribution': ('shuffle' if token_aware.shuffle_replicas + else 'replica-set'), + 'fallback-to-non-preferred-nodes': _falls_back_to_non_preferred_nodes( + located, node_preference), + }, + } + else: + # Named after the policy the application configured, which is the one at + # the top of the chain rather than whichever link made it undescribable. + report = {'policy': _custom_policy_report(policy)} + + if node_preference is not None: + report['node-preference'] = node_preference + return report + + +def _default_fetch_size(): + """ + The default page size, or ``None`` when paging is not limited by default. + + Read off the :class:`~.Session` class rather than an instance: paging is a + session setting in this driver, and no session exists yet when the control + connection reports. What this describes is the default every session created + from that cluster will start with, which is the closest thing to a + cluster-wide answer there is; a session that then sets its own + ``default_fetch_size`` is not reflected here. + + Imported where it is used, since :mod:`cassandra.cluster` imports this + module. + """ + from cassandra.cluster import Session + + # operator.index rather than a check against int: a page size is packed into + # the request as an integer, which takes anything with __index__, so a numpy + # integer paginates exactly as a builtin one does and describing it as + # unlimited would be wrong. It also returns a builtin int, which keeps a + # page size of True out of the report as JSON true where a number belongs. + try: + fetch_size = operator.index(Session.default_fetch_size) + except TypeError: + return None + return fetch_size if fetch_size > 0 else None + + +def _client_timestamps(timestamp_generator): + """ + Whether the client assigns the write timestamp, or ``None`` when that cannot + be answered. + + Two things decide it. :attr:`.Session.use_client_timestamp` gates whether + the generator is consulted at all -- with it off the coordinator assigns + every timestamp, whatever generator the cluster holds. It is read off the + class for the same reason as :func:`_default_fetch_size`: it is a session + setting, and no session exists yet when the control connection reports. + + Then a custom generator is the schema's "unknown": it is called per request + and may return None for some of them, in which case the coordinator assigns + the timestamp after all, and there is no way to tell from here which it will + do. + """ + from cassandra.cluster import Session + + if not Session.use_client_timestamp: + return False + if timestamp_generator is None: + return False + if type(timestamp_generator) is MonotonicTimestampGenerator: + return True + return None + + +def _speculative_delay_ms(delay): + """ + The delay before each additional execution in milliseconds, or ``None`` when + no execution will ever be started with it. + + A negative delay starts nothing: ``next_execution()`` hands the configured + delay straight through, and + :meth:`cassandra.cluster.ResponseFuture._start_timer` creates the + speculative timer only for a delay of zero or more. It is also the very + value the plan returns once it has run out, so the driver cannot tell a + negative delay from an exhausted plan. + + A delay that cannot be compared with zero at all starts nothing either, and + takes the request with it: that comparison is ``_start_timer``'s, and it + raises there. :class:`~.ConstantSpeculativeExecutionPolicy` validates its + arguments no more than it validates the rest, so both are reachable. + + Neither has a value the group can carry -- ``delay-ms`` is a + ``nonNegativeInteger`` -- which is why both come back as the absence the + caller turns into an absent group. + """ + try: + if delay < 0: + return None + except TypeError: + return None + return _non_negative_ms(delay) + + +def _speculative_execution_report(policy): + """ + The ``query.speculative-execution`` group, or ``None`` when the driver will + not start a duplicate execution -- which the schema expresses by leaving the + group out rather than by a policy that does nothing. + """ + if policy is None or type(policy) is NoSpeculativeExecutionPolicy: + return None + + if type(policy) is ConstantSpeculativeExecutionPolicy: + # The delay decides first, because an unusable one starts nothing + # whatever the count says -- including a count of float('inf'), which + # otherwise reaches the custom arm below and reports a policy that races + # as often as it likes while _start_timer never makes it a timer at all. + # No usable delay: see _speculative_delay_ms. + delay_ms = _speculative_delay_ms(policy.delay) + if delay_ms is None: + return None + + # The plan counts `remaining` down while it is above zero, so a + # fractional limit yields the ceiling here too: 0.5 admits one execution + # and 1.5 admits two. + max_executions = _integer_ceiling(policy.max_attempts) + if max_executions is None: + # A limit no integer can express, float('inf') being the one an + # application would reach for to keep racing for as long as the + # request lives. The plan counts down from it and never runs out, so + # the driver does speculate, and leaving the group out would say it + # never does. max-executions is a required positiveInteger with no + # way to say "without limit", so the arm for a policy the shared + # vocabulary cannot describe is the only truthful one left -- as it + # is for a limit that is not a number at all, which is a policy that + # raises when it builds its plan. + return {'policy': _custom_policy_report(policy)} + + if max_executions < 1: + # The other way to configure a policy that never races anything, and + # the group cannot describe it from the inside either: + # max-executions is a required positiveInteger with no way to say + # "none", so absence is how the schema says it -- exactly as for the + # no-op policy above. The plan's next_execution() returns -1 from the + # very first call. + return None + + return {'policy': {'type': 'constant', + 'max-executions': max_executions, + 'delay-ms': delay_ms}} + + return {'policy': _custom_policy_report(policy)} + + class DriverConfigReporter: """ Builds the :const:`DRIVER_CONFIG_OPTION` ``STARTUP`` option describing the @@ -505,6 +1028,7 @@ def _populate_report(self, report, cluster, is_scylla): """ report['connection'] = self._connection_report(cluster) report['control-plane'] = self._control_plane_report(cluster, is_scylla) + report['query'] = self._query_report(cluster) def _connection_report(self, cluster): """ @@ -582,6 +1106,145 @@ def _connection_report(self, cluster): report['tls'] = tls return report + def _query_report(self, cluster): + """ + The ``query`` group: what the driver does with a statement that does not + override any of it. + + Reported from the default execution profile. The schema has one query + group and this driver has as many profiles as the application cares to + define, so the one that describes the session is the one a statement + gets when it names none. Profiles other than the default cannot be + described under this schema version. + + Which of the two configuration modes is live decides where the policies + and the defaults come from, and the profile is not always the answer. + Assigning + :attr:`~.Cluster.default_retry_policy` or + :attr:`~.Cluster.load_balancing_policy` after construction switches the + cluster to legacy mode and updates only the cluster attribute, leaving + the default profile holding whatever it was built with. A request then + takes the cluster's, so reading the profile would describe policies + nothing will ever use. Given to the constructor instead, the two agree, + because the profile is built from those same attributes. + + This is the choice + :meth:`cassandra.cluster.Session._create_response_future` makes for + every request, and + :meth:`cassandra.cluster.ControlConnection._try_connect_to_hosts` for + its own connections. Imported where it is used, since + :mod:`cassandra.cluster` imports this module. + """ + from cassandra.cluster import _ConfigMode + + profile = cluster.profile_manager.default + legacy = cluster._config_mode == _ConfigMode.LEGACY + + report = { + 'defaults': self._query_defaults_report(cluster, profile, legacy), + 'retry': _retry_report( + cluster.default_retry_policy if legacy else profile.retry_policy, + 'default_retry_policy' if legacy else 'retry_policy'), + 'load-balancing': _load_balancing_report( + cluster.load_balancing_policy if legacy else profile.load_balancing_policy), + } + + # Legacy configuration races nothing, whatever the profile holds: the + # legacy branch of _create_response_future leaves its speculative + # execution plan unset, so there is no group to report. + speculative_execution = None if legacy else _speculative_execution_report( + profile.speculative_execution_policy) + if speculative_execution is not None: + report['speculative-execution'] = speculative_execution + return report + + def _query_defaults_report(self, cluster, profile, legacy): + """ + The ``query.defaults`` group. + + A snapshot taken before any :class:`~.Session` exists, so the settings + this driver keeps on the session rather than on the profile are read off + the :class:`~.Session` class -- ``default_fetch_size`` and + ``use_client_timestamp`` always, and the three below in legacy + configuration mode. What that describes is the default every session + created from this cluster will start with, which is the closest thing to + a cluster-wide answer there is; a session that then sets its own is not + reflected here. + + Which of the two configuration modes is live decides where the + consistency, the serial consistency and the request timeout come from, + the same way it does for the policies in :meth:`_query_report`. The + legacy branch of + :meth:`cassandra.cluster.Session._create_response_future` reads + ``default_consistency_level``, ``default_serial_consistency_level`` and + ``default_timeout`` off the session and never looks at the profile, so + reading the profile there would describe a consistency nothing will ever + query at: the profile is built holding + :attr:`.ExecutionProfile.consistency_level`'s own default rather than the + session's. Under profiles the profile is the answer. + """ + from cassandra.cluster import Session + + if legacy: + consistency = Session._default_consistency_level + serial_consistency = Session._default_serial_consistency_level + request_timeout = Session._default_timeout + consistency_setting, serial_setting = ('default_consistency_level', + 'default_serial_consistency_level') + else: + consistency = profile.consistency_level + serial_consistency = profile.serial_consistency_level + request_timeout = profile.request_timeout + consistency_setting, serial_setting = ('consistency_level', + 'serial_consistency_level') + + report = { + 'consistency': _consistency_name(consistency, consistency_setting), + # This driver has no configurable default: Statement.is_idempotent + # is False unless a statement says otherwise, and nothing at cluster + # or profile level changes that. + 'idempotence': False, + } + + # Unset means the server's own default applies, which is not this + # driver's to describe. A level that is not a serial one is not this + # driver's to describe either: ExecutionProfile validates the argument + # its constructor is given and leaves the attribute writable, and + # Session.default_serial_consistency_level's setter validates every + # assignment, so a non-serial level is reachable through the profile + # alone. The schema's enum here is the two serial levels, so naming one + # would put a value in the field no consumer has to accept -- and a + # non-serial level is not one a conditional statement can use anyway. + if serial_consistency is not None: + if ConsistencyLevel.is_serial(serial_consistency): + report['serial-consistency'] = _consistency_name( + serial_consistency, serial_setting) + else: + # Warned rather than passed over: absence in this field means + # the server's default applies, which is not what is happening, + # and the key being optional is the only reason this does not + # take the whole report down the way an unnameable consistency + # does. + log.warning("%s is %r, which is not a serial consistency level; " + "it will be left out of the driver configuration report", + serial_setting, serial_consistency) + + request_timeout_ms = _optional_ms(request_timeout) + if request_timeout_ms is not None: + report['request'] = {'timeout-ms': request_timeout_ms} + + # Paging is a Session setting rather than a profile one, and no Session + # exists yet when the control connection reports: this is the default + # every Session created from now on will start with. + page_size = _default_fetch_size() + if page_size is not None: + report['page'] = {'size': page_size} + + client_timestamps = _client_timestamps(cluster.timestamp_generator) + if client_timestamps is not None: + report['client-timestamps'] = client_timestamps + return report + def _control_plane_report(self, cluster, is_scylla): """ The ``control-plane`` group: the timeouts on the driver's own queries, diff --git a/tests/unit/test_driver_config.py b/tests/unit/test_driver_config.py index 3bb921973d..05f07a8069 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -22,20 +22,41 @@ import ssl import struct import unittest +import uuid +import warnings +from io import BytesIO from unittest import mock from unittest.mock import Mock import numpy import pytest -from cassandra.cluster import Cluster +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster, EXEC_PROFILE_DEFAULT, ExecutionProfile, Session from cassandra.driver_config import (DriverConfigReporter, DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, - _non_negative_ms, _optional_ms, _reconnection_policy_report, - _required_ms, _socket_report) + _load_balancing_report, _non_negative_ms, _optional_ms, + _location_policy, _MAX_POLICY_CHAIN, + _node_location_preference_report, + _reconnection_policy_report, + _required_ms, _retry_report, + _socket_report, _speculative_execution_report) +from cassandra.connection import DefaultEndPoint +from cassandra.pool import Host +from cassandra.protocol import QueryMessage from cassandra.util import maybe_add_timeout_to_query -from cassandra.policies import (ConstantReconnectionPolicy, ExponentialReconnectionPolicy, - ReconnectionPolicy) +from cassandra.policies import (ConstantReconnectionPolicy, ConstantSpeculativeExecutionPolicy, + DCAwareRoundRobinPolicy, DowngradingConsistencyRetryPolicy, + ExponentialBackoffRetryPolicy, ExponentialReconnectionPolicy, + FallthroughRetryPolicy, NeverRetryPolicy, + NoSpeculativeExecutionPlan, NoSpeculativeExecutionPolicy, + DefaultLoadBalancingPolicy, + RackAwareRoundRobinPolicy, ReconnectionPolicy, RetryPolicy, + SimpleConvictionPolicy, + HostFilterPolicy, RoundRobinPolicy, + SpeculativeExecutionPolicy, TokenAwarePolicy, + WhiteListRoundRobinPolicy) +from tests.driver_config_schema import load_schema, validate_report from tests.unit.utils import _ClusterlessReporter, ThrowingReporter @@ -71,11 +92,11 @@ def reporter(test, **cluster_kwargs): return cluster._driver_config_reporter -def report_text(test, **cluster_kwargs): +def report_text(test, is_scylla=True, **cluster_kwargs): """The report of a real Cluster, as it goes on the wire.""" cluster = Cluster(**cluster_kwargs) test.addCleanup(cluster.shutdown) - return cluster._driver_config_reporter._build_report(cluster, is_scylla=True) + return cluster._driver_config_reporter._build_report(cluster, is_scylla=is_scylla) class DriverConfigReporterTest(unittest.TestCase): @@ -952,3 +973,1143 @@ def test_not_waiting_for_schema_agreement_is_a_value(self): report = control_plane_report(self, max_schema_agreement_wait=0) assert report['schema']['agreement']['timeout-ms'] == 0 + + +def full_report(test, is_scylla=True, **cluster_kwargs): + """ + The parsed report, validated on the way through. + + Every configuration any test here builds is one this driver may really send, + so each of them is a conformance case too -- cheaper and harder to forget + than adding one to ReportConformsToTheSchemaTest by hand. + """ + return validate_report(report_text(test, is_scylla=is_scylla, **cluster_kwargs)) + + +def query_report(test, profile=None, **cluster_kwargs): + if profile is not None: + cluster_kwargs['execution_profiles'] = {EXEC_PROFILE_DEFAULT: profile} + return full_report(test, **cluster_kwargs)['query'] + + +class QueryDefaultsTest(unittest.TestCase): + def test_defaults(self): + assert query_report(self)['defaults'] == { + 'consistency': 'LOCAL_ONE', + 'idempotence': False, + 'request': {'timeout-ms': 10000}, + 'page': {'size': 5000}, + 'client-timestamps': True, + } + + def test_consistency_is_reported_by_name(self): + """ + The wire form is an integer and the schema wants the name. + """ + report = query_report(self, ExecutionProfile(consistency_level=ConsistencyLevel.QUORUM)) + + assert report['defaults']['consistency'] == 'QUORUM' + + def test_every_consistency_level_has_a_name_the_schema_accepts(self): + """ + The driver's levels and the schema's enum have to stay in step: a level + this driver has and the schema does not would be reported and rejected. + """ + schema = load_schema() + accepted = schema['$defs']['query-defaults']['properties']['consistency']['enum'] + + assert set(ConsistencyLevel.value_to_name.values()) == set(accepted) + + def test_serial_consistency(self): + report = query_report(self, ExecutionProfile( + serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL)) + + assert report['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + + def test_serial_consistency_is_left_out_when_unset(self): + """ + Unset means the server's own default applies, which is not this driver's + to describe. + """ + assert 'serial-consistency' not in query_report(self)['defaults'] + + def test_a_level_that_is_not_serial_is_left_out_and_warned_about(self): + """ + ExecutionProfile validates the argument its constructor is given and + leaves the attribute writable, so a non-serial level is reachable. The + schema's enum here is the two serial levels, and naming one anyway is + the only way a live Cluster could produce a document the shared contract + rejects. + + Warned rather than passed over: absence in this field means the server's + default applies, which is not what is happening, and the key being + optional is the only reason this does not take the whole report down the + way an unnameable consistency does. + """ + profile = ExecutionProfile() + cluster = Cluster(execution_profiles={EXEC_PROFILE_DEFAULT: profile}) + self.addCleanup(cluster.shutdown) + profile.serial_consistency_level = ConsistencyLevel.QUORUM + + with self.assertLogs('cassandra.driver_config', level='WARNING') as captured: + report = validate_report( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True)) + + assert 'serial-consistency' not in report['query']['defaults'] + assert 'serial_consistency_level is 4' in '\n'.join( + r.getMessage() for r in captured.records) + + def test_request_timeout(self): + report = query_report(self, ExecutionProfile(request_timeout=2.5)) + + assert report['defaults']['request'] == {'timeout-ms': 2500} + + def test_request_timeout_is_left_out_when_disabled(self): + report = query_report(self, ExecutionProfile(request_timeout=None)) + + assert 'request' not in report['defaults'] + + def test_idempotence_is_always_false(self): + """ + This driver has no configurable default: a statement is not idempotent + unless it says so, and nothing at cluster or profile level changes that. + """ + assert query_report(self)['defaults']['idempotence'] is False + + def test_a_page_size_of_any_integer_type_is_reported(self): + """ + A page size is packed into the request as an integer, which takes + anything with __index__, so a numpy integer paginates exactly as a + builtin one does -- reporting nothing for it says paging is unlimited. + A bool is one row per page, and is reported as the number 1, since the + schema wants an integer and JSON true is not one. + """ + for value, expected in ((numpy.int64(123), 123), (True, 1), (5000, 5000)): + with mock.patch.object(Session, 'default_fetch_size', value): + report = query_report(self)['defaults'] + + assert report['page'] == {'size': expected}, value + assert type(report['page']['size']) is int, value + + def test_a_page_size_that_limits_nothing_is_left_out(self): + for value in (None, 0, 2.5): + with mock.patch.object(Session, 'default_fetch_size', value): + report = query_report(self)['defaults'] + + assert 'page' not in report, value + + def test_client_timestamps(self): + """ + The default generator assigns the timestamp client-side. + """ + assert query_report(self)['defaults']['client-timestamps'] is True + + def test_client_timestamps_are_off_when_the_session_does_not_use_them(self): + """ + use_client_timestamp gates whether the generator is consulted at all, so + with it off the coordinator assigns every timestamp whatever generator + the cluster holds. Read off the class for the same reason as the page + size: it is a session setting, and no session exists yet. + """ + with mock.patch.object(Session, 'use_client_timestamp', False): + report = query_report(self) + + assert report['defaults']['client-timestamps'] is False + + def test_client_timestamps_are_unknown_with_a_custom_generator(self): + """ + A custom generator is called per request and may return None for some of + them, leaving the coordinator to assign the timestamp after all. The + schema's way of saying that is to leave the key out. + """ + report = query_report(self, timestamp_generator=lambda: 1234) + + assert 'client-timestamps' not in report['defaults'] + + +class RetryReportTest(unittest.TestCase): + def test_built_in_policies(self): + for policy, expected in ( + (RetryPolicy(), 'standard-error-aware'), + (FallthroughRetryPolicy(), 'fallthrough'), + (NeverRetryPolicy(), 'never'), + (DowngradingConsistencyRetryPolicy(), 'downgrading-consistency')): + assert _retry_report(policy, 'retry_policy') == {'policy': {'type': expected}}, policy + + def test_no_policy_is_not_a_fallthrough(self): + """ + The fallthrough arm means the driver rethrows the original error to the + caller untouched. With no policy at all, ResponseFuture calls + on_request_error on None and raises AttributeError instead, losing the + original error -- so naming it fallthrough would describe a working + configuration where there is a broken one. + + ExecutionProfile replaces a None its constructor is given, but both it + and Cluster.default_retry_policy stay writable, so this is reachable. + """ + profile = ExecutionProfile() + assert profile.retry_policy is not None # the constructor replaced it + profile.retry_policy = None # but nothing stops this + + with pytest.raises(AttributeError): + profile.retry_policy.on_request_error(None, 1, error=None, retry_num=0) + + with pytest.raises(ValueError, match='retry_policy is None'): + _retry_report(None, 'retry_policy') + + def test_a_report_is_dropped_rather_than_naming_a_policy_that_is_not_used(self): + """ + policy is a required key, so there is no conformant document for such a + configuration -- the whole report goes, as it does for a consistency + level the driver cannot name. + """ + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.profile_manager.default.retry_policy = None + options = {} + + cluster._driver_config_reporter.add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options + + def test_dispatch_is_on_the_exact_type(self): + """ + Every built-in above is a subclass of RetryPolicy, so isinstance would + report all of them as the standard policy. This is the mistake the + mapping is most likely to make. + """ + assert _retry_report(FallthroughRetryPolicy(), 'retry_policy')['policy']['type'] == 'fallthrough' + + class Tweaked(FallthroughRetryPolicy): + pass + + assert _retry_report(Tweaked(), 'retry_policy')['policy'] == {'type': 'custom', 'name': 'Tweaked'} + + def test_exponential_backoff_is_the_standard_policy_with_a_backoff(self): + """ + It retries what the standard policy retries and adds a growing delay, + which is what the schema's backoff describes. + """ + report = _retry_report(ExponentialBackoffRetryPolicy( + max_num_retries=4, min_interval=0.2, max_interval=5.0), 'retry_policy') + + assert report == { + 'policy': {'type': 'standard-error-aware', 'max-retries': 4}, + 'backoff': {'type': 'exponential', 'base-ms': 200, 'max-ms': 5000}, + } + + def test_intervals_given_the_wrong_way_round(self): + """ + _calculate_backoff caps the whole curve at max_interval, so the initial + delay is min(max_interval, min_interval) and not min_interval. The policy + does not check the order, so reporting min_interval would claim a first + delay it never waits. That also keeps the schema's requirement that + max-ms be at least base-ms true by construction. + """ + policy = ExponentialBackoffRetryPolicy( + max_num_retries=1, min_interval=10.0, max_interval=1.0) + # The un-jittered curve is flat at max_interval, never at min_interval. + assert [min(1.0, 10.0 * 2 ** a) for a in range(4)] == [1.0, 1.0, 1.0, 1.0] + + report = _retry_report(policy, 'retry_policy') + + assert report['backoff']['base-ms'] == 1000 + assert report['backoff']['max-ms'] == 1000 + + def test_a_backoff_maximum_is_never_below_its_base(self): + for mi, mx in ((10.0, 1.0), (0.1, 10.0), (5.0, 5.0), (0.0004, 0.0009)): + backoff = _retry_report( + ExponentialBackoffRetryPolicy(1, mi, mx), 'retry_policy')['backoff'] + assert backoff['max-ms'] >= backoff['base-ms'], (mi, mx) + + def test_a_backoff_that_never_delays_is_left_out(self): + """ + _calculate_backoff is min(max_interval, min_interval * 2 ** attempt) + plus jitter scaled by min_interval, so a min_interval of zero is zero at + every attempt whatever max_interval says. The schema leaves backoff out + for exactly that, and rejects a delay of zero inside it. + """ + policy = ExponentialBackoffRetryPolicy(3, min_interval=0, max_interval=10.0) + assert [policy._calculate_backoff(a) for a in range(4)] == [0, 0, 0, 0] + + report = _retry_report(policy, 'retry_policy') + + assert 'backoff' not in report + # The policy itself is still described, retries and all. + assert report['policy'] == {'type': 'standard-error-aware', 'max-retries': 3} + + def test_no_retries_is_a_value(self): + """ + max-retries is a nonNegativeInteger whose zero the schema spells "no + retries", so unlike the counts elsewhere in the report this one says + what it means and needs no special case. + """ + assert ExponentialBackoffRetryPolicy(0, 0.1, 1.0).on_read_timeout( + None, 1, 1, 1, False, 0)[0] == RetryPolicy.RETHROW + assert _retry_report( + ExponentialBackoffRetryPolicy(0, 0.1, 1.0), 'retry_policy')['policy']['max-retries'] == 0 + # Negative counts mean the same thing and cannot be reported as such. + assert _retry_report( + ExponentialBackoffRetryPolicy(-2, 0.1, 1.0), 'retry_policy')['policy']['max-retries'] == 0 + + def test_a_fractional_retry_limit_rounds_up(self): + """ + Every on_* method gives up once retry_num reaches max_num_retries, and + the comparison is `<`, so 0.5 still permits one retry. Truncating + reports zero, which the schema reads as no retries at all -- the + opposite of what the policy does. The attribute is typed float, so + fractions are an expected input rather than an abuse. + """ + for limit, retries in ((0.5, 1), (1.5, 2), (2.5, 3)): + policy = ExponentialBackoffRetryPolicy(limit, 0.1, 1.0) + permitted = sum( + policy.on_read_timeout(None, 1, 1, 1, False, n)[0] == RetryPolicy.RETRY + for n in range(10)) + assert permitted == retries, limit + + assert _retry_report(policy, 'retry_policy')['policy']['max-retries'] == retries, limit + + def test_a_retry_limit_no_integer_can_express_leaves_the_key_out(self): + """ + max_num_retries is typed float, so float('inf') is how an application + says "retry until the request runs out of time" -- and the policy really + does honour it, since every on_* method only ever compares against it. + No integer names that limit, and an absent max-retries is the schema's + way of saying none was configured, which is the closest true thing. + """ + policy = ExponentialBackoffRetryPolicy(float('inf'), 0.1, 1.0) + assert all(policy.on_read_timeout(None, 1, 1, 1, False, n)[0] == RetryPolicy.RETRY + for n in range(100)) + + assert 'max-retries' not in _retry_report(policy, 'retry_policy')['policy'] + assert _retry_report(policy, 'retry_policy')['policy']['type'] == 'standard-error-aware' + + def test_an_unnameable_retry_limit_does_not_cost_the_rest_of_the_report(self): + """ + The regression this guards: math.ceil raises OverflowError on inf and + TypeError on anything that is not a number, and one unnameable limit + used to take every other group down with it -- the connection settings, + the control-plane timeouts, all of it. + """ + for limit in (float('inf'), float('nan'), None, 'lots'): + report = full_report(self, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + retry_policy=ExponentialBackoffRetryPolicy(limit, 0.1, 1.0))}) + + assert 'max-retries' not in report['query']['retry']['policy'], limit + assert report['connection']['connect']['timeout-ms'] == 5000, limit + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveRetryPolicy(RetryPolicy): + def __init__(self): + self.password = 'hunter2' + + assert _retry_report(SecretiveRetryPolicy(), 'retry_policy') == { + 'policy': {'type': 'custom', 'name': 'SecretiveRetryPolicy'}} + + +class LoadBalancingReportTest(unittest.TestCase): + def test_token_aware_over_a_datacenter_aware_child(self): + report = _load_balancing_report(TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'))) + + assert report == { + 'policy': {'type': 'token-aware', 'load-distribution': 'shuffle', + 'fallback-to-non-preferred-nodes': False}, + 'node-preference': {'type': 'dc', 'local-dc': 'dc1'}, + } + + def test_load_distribution_follows_replica_shuffling(self): + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'), shuffle_replicas=False) + + assert _load_balancing_report(policy)['policy']['load-distribution'] == 'replica-set' + + def test_fallback_to_non_preferred_nodes(self): + """ + The datacenter-aware policies ignore remote hosts entirely until they + are told how many to use. + """ + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1', used_hosts_per_remote_dc=2)) + + assert _load_balancing_report(policy)['policy']['fallback-to-non-preferred-nodes'] is True + + def test_only_the_preferences_this_driver_reports_are_handled(self): + """ + The schema has a rack-auto arm and this driver never produces it: + RackAwareRoundRobinPolicy takes both the datacenter and the rack as + mandatory constructor arguments and never infers either. Pinned so that + the flag's handling stays matched to what is actually reported. + """ + class Host: + datacenter, rack, endpoint = 'inferred', 'r', 'e' + + inferred = DCAwareRoundRobinPolicy() + inferred.on_up(Host()) + + emitted = set() + for policy in (DCAwareRoundRobinPolicy(), DCAwareRoundRobinPolicy('dc1'), + DCAwareRoundRobinPolicy(''), inferred, + RackAwareRoundRobinPolicy('dc1', 'rack1'), + RackAwareRoundRobinPolicy('dc1', ''), + RackAwareRoundRobinPolicy('', 'rack1'), + RackAwareRoundRobinPolicy('', ''), + RoundRobinPolicy(), None): + reported = _node_location_preference_report(policy) + emitted.add(reported['type'] if reported else None) + + assert emitted == {'dc', 'dc-auto', 'rack', None} + + def test_a_rack_preference_always_falls_back(self): + """ + RackAwareRoundRobinPolicy's query plan yields the local datacenter's + other racks straight after the local-rack tier, unconditionally -- + used_hosts_per_remote_dc gates only the remote datacenters below that. + So a request routinely reaches a node the reported rack preference + excludes, whatever that setting says. + """ + for remote in (0, 2): + policy = TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=remote)) + report = _load_balancing_report(policy) + + assert report['node-preference']['type'] == 'rack' + assert report['policy']['fallback-to-non-preferred-nodes'] is True, remote + + def test_the_rack_tier_really_is_unconditional(self): + """ + The premise of the test above, read off the policy rather than assumed: + with no remote hosts allowed, a host in the local datacenter but another + rack is still in the query plan. + """ + policy = RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=0) + local = Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'dc1', 'rack1', + host_id=uuid.uuid4()) + other_rack = Host(DefaultEndPoint(2), SimpleConvictionPolicy, 'dc1', 'rack2', + host_id=uuid.uuid4()) + policy.populate(Mock(), [local, other_rack]) + + assert other_rack in list(policy.make_query_plan()) + + def test_a_rack_aware_policy_without_a_rack_is_judged_as_a_datacenter_one(self): + """ + It reports a datacenter preference, so the flag has to be answered + against that: other racks are inside the preference, not outside it. + """ + for remote, expected in ((0, False), (2, True)): + policy = TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', '', used_hosts_per_remote_dc=remote)) + report = _load_balancing_report(policy) + + assert report['node-preference']['type'] == 'dc' + assert report['policy']['fallback-to-non-preferred-nodes'] is expected, remote + + def test_no_preference_means_nothing_to_fall_outside_of(self): + """ + Not because such a chain keeps requests anywhere -- round robin treats + every host as local and will happily reach a remote datacenter. It + reports false because it declares no preference for a request to fall + outside of, and no node-preference is reported for it either, which is + what the flag is defined against. The other ScyllaDB drivers do not all + answer this the same way, so it is a deliberate choice. + """ + report = _load_balancing_report(TokenAwarePolicy(RoundRobinPolicy())) + + assert 'node-preference' not in report + assert report['policy']['fallback-to-non-preferred-nodes'] is False + + def test_an_inferred_datacenter(self): + """ + Not yet known at report time is a state the schema allows for, and the + one the first control connection is usually in. + """ + policy = TokenAwarePolicy(DCAwareRoundRobinPolicy()) + + assert _load_balancing_report(policy)['node-preference'] == {'type': 'dc-auto'} + + def test_an_inferred_datacenter_once_it_is_known(self): + child = DCAwareRoundRobinPolicy() + # Driven through on_up, which is what infers: assigning local_dc is the + # application choosing one, and is reported as such. + child.on_up(Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'inferred-dc', + host_id=uuid.uuid4())) + + report = _load_balancing_report(TokenAwarePolicy(child)) + + assert report['node-preference'] == {'type': 'dc-auto', 'local-dc': 'inferred-dc'} + + def test_the_datacenter_cannot_be_reassigned(self): + """ + local_dc is read-only, so a configured datacenter and an inferred one + cannot be confused: an assignment afterwards would be indistinguishable + from on_up's inference, and telling them apart is the whole point of the + dc / dc-auto distinction. + """ + policy = DCAwareRoundRobinPolicy('dc1') + + with pytest.raises(AttributeError): + policy.local_dc = 'dc2' + + assert policy.local_dc == 'dc1' + assert _node_location_preference_report(policy) == {'type': 'dc', 'local-dc': 'dc1'} + + def test_inference_still_fills_in_an_unset_datacenter(self): + """ + The other half: read-only to the application, still filled in by on_up + when the constructor was given nothing -- and reported as inferred. + """ + policy = DCAwareRoundRobinPolicy() + assert _node_location_preference_report(policy) == {'type': 'dc-auto'} + + policy.on_up(Host(DefaultEndPoint(1), SimpleConvictionPolicy, 'inferred', + host_id=uuid.uuid4())) + + assert policy.local_dc == 'inferred' + assert _node_location_preference_report(policy) == { + 'type': 'dc-auto', 'local-dc': 'inferred'} + + def test_a_rack_aware_child(self): + policy = TokenAwarePolicy(RackAwareRoundRobinPolicy('dc1', 'rack1')) + + assert _load_balancing_report(policy)['node-preference'] == { + 'type': 'rack', 'local-dc': 'dc1', 'local-rack': 'rack1'} + + def test_policies_that_are_not_token_aware_are_custom(self): + """ + Only the token-aware policy maps onto the schema's built-in arm. The + round-robin policies are built in to this driver but are not token + aware, and the shared vocabulary has no term for them. + """ + for policy in (RoundRobinPolicy(), DCAwareRoundRobinPolicy('dc1'), + WhiteListRoundRobinPolicy([])): + report = _load_balancing_report(policy) + assert report['policy'] == {'type': 'custom', + 'name': type(policy).__name__}, policy + + def test_a_custom_policy_still_reports_its_datacenter(self): + """ + The preference is a sibling of the policy in the schema, not a property + of the built-in arm. A bare DCAwareRoundRobinPolicy -- which is what + default_lbp_factory() returns without the murmur3 extension -- pins the + driver to a datacenter just as firmly as a token-aware one wrapping it, + and an operator cannot tell that from the type name alone. + """ + report = _load_balancing_report(DCAwareRoundRobinPolicy('dc1')) + + assert report == { + 'policy': {'type': 'custom', 'name': 'DCAwareRoundRobinPolicy'}, + 'node-preference': {'type': 'dc', 'local-dc': 'dc1'}, + } + + def test_a_bare_rack_aware_policy_reports_its_rack(self): + """ + RackAwareRoundRobinPolicy takes both as mandatory arguments, so the most + deliberate pinning an application can express is also the one most + likely to be used without a token-aware wrapper. + """ + report = _load_balancing_report(RackAwareRoundRobinPolicy('dc1', 'rack1')) + + assert report == { + 'policy': {'type': 'custom', 'name': 'RackAwareRoundRobinPolicy'}, + 'node-preference': {'type': 'rack', 'local-dc': 'dc1', + 'local-rack': 'rack1'}, + } + + def test_the_preference_is_reported_even_for_an_undescribable_chain(self): + """ + node-preference is a sibling of the policy rather than part of it, so a + chain the built-in arm cannot describe still says where the driver is + pinned. The policy itself goes to the custom arm: HostFilterPolicy + admits only what an application-supplied predicate allows, which the + token-aware flags have nowhere to record. + """ + policy = TokenAwarePolicy( + HostFilterPolicy(DCAwareRoundRobinPolicy('dc1', used_hosts_per_remote_dc=2), + lambda host: True)) + + report = _load_balancing_report(policy) + + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'} + assert report['policy'] == {'type': 'custom', 'name': 'TokenAwarePolicy'} + + def test_a_chain_reaching_an_unknown_policy_is_custom(self): + """ + The built-in arm's flags describe the routing of the whole chain, so + they can only be filled in when every policy in it is one this module + knows. Reporting them over an unknown child would assert plain + token-aware routing and say nothing of what the child does -- + WhiteListRoundRobinPolicy confines routing to a fixed host list, and a + RoundRobinPolicy subclass at that, which is why the check is on exact + types. + """ + class MyCustomPolicy(RoundRobinPolicy): + pass + + for child in (WhiteListRoundRobinPolicy([]), + HostFilterPolicy(RoundRobinPolicy(), lambda host: True), + MyCustomPolicy()): + report = _load_balancing_report(TokenAwarePolicy(child)) + + assert report['policy'] == {'type': 'custom', 'name': 'TokenAwarePolicy'}, child + + def test_token_awareness_is_found_under_a_transparent_wrapper(self): + """ + A wrapper above the token-aware policy does not stop the routing being + token aware, so the arm is claimed from anywhere in the chain. + """ + policy = DefaultLoadBalancingPolicy( + TokenAwarePolicy(DCAwareRoundRobinPolicy('dc1'))) + + report = _load_balancing_report(policy) + + assert report['policy']['type'] == 'token-aware' + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'} + + def test_no_preference_when_nothing_in_the_chain_is_location_aware(self): + for policy in (RoundRobinPolicy(), TokenAwarePolicy(RoundRobinPolicy())): + assert 'node-preference' not in _load_balancing_report(policy), policy + + def test_the_preference_is_found_however_deep_it_sits(self): + """ + Stopping the walk early is not free: it reports no location preference + at all, which reads as a client pinned to nothing rather than one whose + preference sits deeper than the walk went. Nothing about a wrapper + changes where requests go, so depth must not decide what is reported. + """ + for depth in (1, 7, 8, 12, 200): + policy = DCAwareRoundRobinPolicy('dc1') + for _ in range(depth): + policy = HostFilterPolicy(policy, lambda host: True) + + report = _load_balancing_report(TokenAwarePolicy(policy)) + + assert report['node-preference'] == {'type': 'dc', 'local-dc': 'dc1'}, depth + + def test_a_self_referential_chain_terminates(self): + """ + The walk stops once it reaches a policy it has already seen, which is + what a chain looping back on itself does. This runs while a connection + is being established, and a walk that never ends would hang the + handshake. + """ + policy = HostFilterPolicy(RoundRobinPolicy(), lambda host: True) + policy._child_policy = policy + + assert 'node-preference' not in _load_balancing_report(policy) + + def test_the_chain_is_walked_once(self): + """ + The group needs three answers about a chain, and taking them from + separate walks costs the walk over again -- _MAX_POLICY_CHAIN policy + objects each, for the chain that bound exists for, while a connection is + being established. + + It also lets the answers describe different chains: a _child_policy + returning something different on each access hands each walk its own, + so one can find a token-aware policy where the next finds none. + """ + built = [] + + class Endless(RoundRobinPolicy): + @property + def _child_policy(self): + built.append(None) + return Endless() + + _load_balancing_report(Endless()) + + assert len(built) == _MAX_POLICY_CHAIN + + def test_a_chain_that_manufactures_children_terminates(self): + """ + The case identity cannot catch, and what the backstop is for: every + access returns a new object, so no step is ever somewhere the walk has + been before. + """ + class EndlessPolicy(RoundRobinPolicy): + @property + def _child_policy(self): + return EndlessPolicy() + + assert _location_policy(EndlessPolicy()) is None + + def test_identity_rather_than_equality_decides_a_loop(self): + """ + A custom policy is free to compare equal to a different policy, which + must not read as a chain that loops back on itself. + """ + class EqualToAnything(RoundRobinPolicy): + def __eq__(self, other): + return True + + __hash__ = None # as Python does for anything defining __eq__ + + policy = EqualToAnything() + policy._child_policy = EqualToAnything() + policy._child_policy._child_policy = DCAwareRoundRobinPolicy('dc1') + + assert _location_policy(policy).local_dc == 'dc1' + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveLoadBalancingPolicy(RoundRobinPolicy): + def __init__(self): + self.password = 'hunter2' + + assert _load_balancing_report(SecretiveLoadBalancingPolicy()) == { + 'policy': {'type': 'custom', 'name': 'SecretiveLoadBalancingPolicy'}} + + +class SpeculativeExecutionReportTest(unittest.TestCase): + def test_absent_by_default(self): + """ + The schema leaves the group out rather than carrying a policy that does + nothing, and doing nothing is this driver's default. + """ + assert _speculative_execution_report(NoSpeculativeExecutionPolicy()) is None + assert _speculative_execution_report(None) is None + assert 'speculative-execution' not in query_report(self) + + def test_constant(self): + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay=0.5, max_attempts=3)) + + assert report == {'policy': {'type': 'constant', 'max-executions': 3, + 'delay-ms': 500}} + + def test_launching_immediately_is_a_value(self): + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay=0, max_attempts=1)) + + assert report['policy']['delay-ms'] == 0 + + def test_a_policy_that_never_speculates_is_absent_too(self): + """ + max-executions is a required positiveInteger, so the group cannot say + "none" from the inside. A policy configured with no attempts never + speculates -- next_execution() returns -1 from the first call, and + ResponseFuture only schedules a delay of zero or more -- so reporting + one execution would claim a race the driver never runs. + """ + for attempts in (0, -1): + plan = ConstantSpeculativeExecutionPolicy(0.5, attempts).new_plan('ks', None) + assert plan.next_execution('host') == -1, attempts + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, attempts)) is None, attempts + + def test_a_policy_that_never_speculates_leaves_a_conformant_report(self): + report = validate_report(report_text(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.5, 0))})) + + assert 'speculative-execution' not in report['query'] + + def test_a_negative_delay_never_races_anything(self): + """ + next_execution hands the configured delay straight through, and + ResponseFuture._start_timer creates the speculative timer only for a + delay of zero or more. A negative delay is also the very value the plan + returns once it has run out, so the driver cannot tell the two apart -- + neither starts an execution. Reporting the group would claim a race that + never happens, and delay-ms cannot carry the negative anyway. + """ + for delay in (-1, -0.001, -60): + plan = ConstantSpeculativeExecutionPolicy(delay, 5).new_plan('ks', None) + # What _start_timer tests before making a timer. + assert plan.next_execution('host') < 0, delay + + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(delay, 5)) is None, delay + + def test_an_unusable_delay_wins_over_an_unlimited_count(self): + """ + A count no integer can express reaches the custom arm, but only if the + policy races at all. next_execution hands the delay straight through and + _start_timer makes a timer only for zero or more, so a negative delay + starts nothing however many executions were asked for -- reporting the + group would claim a race that never runs. + """ + for delay in (-1, -0.001): + policy = ConstantSpeculativeExecutionPolicy(delay, float('inf')) + plan = policy.new_plan('ks', None) + # What _start_timer tests before making a timer. + assert plan.next_execution('host') < 0, delay + + assert _speculative_execution_report(policy) is None, delay + + # A usable delay with the same count still reaches the custom arm. + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, float('inf'))) == { + 'policy': {'type': 'custom', + 'name': 'ConstantSpeculativeExecutionPolicy'}} + + def test_a_zero_delay_still_races(self): + """ + The boundary the driver itself draws: zero is scheduled, below it is not. + """ + report = _speculative_execution_report(ConstantSpeculativeExecutionPolicy(0, 2)) + + assert report['policy']['delay-ms'] == 0 + + def test_a_sub_millisecond_delay_is_not_an_immediate_one(self): + """ + Sub-millisecond speculative execution is a real setting for a + low-latency workload, and must not read as "launch immediately". + """ + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.0004, 2))['policy']['delay-ms'] == 1 + + def test_a_fractional_execution_limit_rounds_up(self): + """ + The plan counts `remaining` down while it is above zero, so a fractional + limit admits the ceiling. Half an execution is still one, and omitting + the group for it would say speculative execution is disabled when it + runs. + """ + for limit, executions in ((0.5, 1), (1.5, 2), (2.5, 3)): + plan = ConstantSpeculativeExecutionPolicy(0.5, limit).new_plan('ks', None) + launched = 0 + while plan.next_execution('host') >= 0: + launched += 1 + assert launched == executions, limit + + report = _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, limit)) + assert report['policy']['max-executions'] == executions, limit + + def test_an_execution_limit_no_integer_can_express_is_a_policy_with_no_name(self): + """ + float('inf') is how an application says "keep racing for as long as the + request lives", and the plan honours it: it counts down from inf and + never runs out. max-executions is a required positiveInteger with no way + to say that, and leaving the group out would claim the driver never + speculates when it always does -- so the only truthful arm left is the + one for a policy the shared vocabulary cannot describe. + """ + policy = ConstantSpeculativeExecutionPolicy(0.5, float('inf')) + plan = policy.new_plan('ks', None) + assert all(plan.next_execution('host') >= 0 for _ in range(100)) + + assert _speculative_execution_report(policy) == { + 'policy': {'type': 'custom', 'name': 'ConstantSpeculativeExecutionPolicy'}} + + def test_a_limit_that_is_not_a_number_is_reported_the_same_way(self): + """ + The policy validates nothing, so a limit it will raise on when it builds + its plan is reachable. There is still a policy configured, which is more + than an absent group would say, and it is no more describable than an + unlimited one. + """ + for limit in (None, 'two'): + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(0.5, limit)) == { + 'policy': {'type': 'custom', + 'name': 'ConstantSpeculativeExecutionPolicy'}}, limit + + def test_a_delay_that_cannot_be_compared_never_races_anything(self): + """ + _start_timer is what compares the delay with zero, so a delay that + cannot be compared raises there and no execution is ever started -- the + same outcome as a negative one, and the same absent group. + """ + assert _speculative_execution_report( + ConstantSpeculativeExecutionPolicy(None, 3)) is None + + def test_an_unnameable_policy_does_not_cost_the_rest_of_the_report(self): + """ + As for retries: math.ceil used to raise here and take every other group + of the report with it. + """ + for delay, limit in ((0.5, float('inf')), (0.5, None), (None, 3)): + report = full_report(self, execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(delay, limit))}) + + assert report['connection']['connect']['timeout-ms'] == 5000, (delay, limit) + + def test_a_custom_policy_is_named_and_nothing_more(self): + class SecretiveSpeculativeExecutionPolicy(SpeculativeExecutionPolicy): + def __init__(self): + self.password = 'hunter2' + + def new_plan(self, keyspace, statement): + return NoSpeculativeExecutionPlan() + + assert _speculative_execution_report(SecretiveSpeculativeExecutionPolicy()) == { + 'policy': {'type': 'custom', 'name': 'SecretiveSpeculativeExecutionPolicy'}} + + +class ProfileSourceTest(unittest.TestCase): + def test_the_default_profile_is_what_is_reported(self): + report = query_report(self, ExecutionProfile( + consistency_level=ConsistencyLevel.THREE, + retry_policy=FallthroughRetryPolicy())) + + assert report['defaults']['consistency'] == 'THREE' + assert report['retry']['policy']['type'] == 'fallthrough' + + def test_other_profiles_are_not_reported(self): + """ + The schema has one query group and this driver has as many profiles as + the application defines, so the one a statement gets when it names none + is the one that describes the session. + """ + cluster = Cluster(execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=ConsistencyLevel.ONE), + 'other': ExecutionProfile(consistency_level=ConsistencyLevel.ALL), + }) + self.addCleanup(cluster.shutdown) + + report = json.loads(cluster._driver_config_reporter._build_report(cluster, True)) + + assert report['query']['defaults']['consistency'] == 'ONE' + + def test_policies_assigned_after_construction_are_reported(self): + """ + Assigning either legacy policy switches the cluster to legacy mode and + updates only the cluster attribute; the default profile keeps whatever + it was built with. A request takes the cluster's, so reading the profile + would describe policies nothing will ever use -- here, retries and + token-aware routing that are not going to happen. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.default_retry_policy = FallthroughRetryPolicy() + cluster.load_balancing_policy = RoundRobinPolicy() + + # The profile still holds the construction-time policies, which is + # what makes this worth asserting. + profile = cluster.profile_manager.default + assert type(profile.retry_policy) is RetryPolicy + assert type(profile.load_balancing_policy) is TokenAwarePolicy + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True))['query'] + + assert report['retry']['policy'] == {'type': 'fallthrough'} + assert report['load-balancing']['policy'] == {'type': 'custom', + 'name': 'RoundRobinPolicy'} + + def test_legacy_configuration_races_nothing(self): + """ + The legacy branch of _create_response_future leaves the speculative + execution plan unset whatever the profile holds, so there is no group to + report even when a policy was put on the profile by hand. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster() + self.addCleanup(cluster.shutdown) + cluster.default_retry_policy = FallthroughRetryPolicy() + cluster.profile_manager.default.speculative_execution_policy = \ + ConstantSpeculativeExecutionPolicy(0.5, 2) + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True))['query'] + + assert 'speculative-execution' not in report + + def test_legacy_configuration_reads_the_same(self): + """ + A load balancing or retry policy given to the Cluster constructor is + folded into the default profile, so both ways of configuring the driver + report identically. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + report = query_report(self, load_balancing_policy=RoundRobinPolicy(), + default_retry_policy=FallthroughRetryPolicy()) + + assert report['retry']['policy']['type'] == 'fallthrough' + assert report['load-balancing']['policy'] == {'type': 'custom', + 'name': 'RoundRobinPolicy'} + + def test_legacy_defaults_come_from_the_session_not_the_profile(self): + """ + The legacy branch of _create_response_future reads the consistency, the + serial consistency and the timeout off the Session and never looks at + the profile, so the profile's values are ones no request will ever use. + + The two agree by default, which is why this sets the profile away from + them: the default profile is built with Session._default_timeout but + with ExecutionProfile's own consistency default, so a report reading the + profile is wrong about the consistency and right about the timeout by + coincidence. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + cluster = Cluster(default_retry_policy=FallthroughRetryPolicy()) + self.addCleanup(cluster.shutdown) + profile = cluster.profile_manager.default + profile.consistency_level = ConsistencyLevel.ALL + profile.serial_consistency_level = ConsistencyLevel.SERIAL + profile.request_timeout = 99 + + report = json.loads( + cluster._driver_config_reporter._build_report(cluster, is_scylla=True)) + + defaults = report['query']['defaults'] + assert defaults['consistency'] == 'LOCAL_ONE' + assert 'serial-consistency' not in defaults + assert defaults['request']['timeout-ms'] == 10000 + + def test_legacy_defaults_follow_the_session_class(self): + """ + Read off the class rather than an instance because no Session exists + when the control connection reports: what this describes is the default + every session created from the cluster will start with. + """ + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + with mock.patch.multiple( + Session, + _default_consistency_level=ConsistencyLevel.QUORUM, + _default_serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL, + _default_timeout=42.0): + report = full_report(self, default_retry_policy=FallthroughRetryPolicy()) + + assert report['query']['defaults']['consistency'] == 'QUORUM' + assert report['query']['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + assert report['query']['defaults']['request']['timeout-ms'] == 42000 + + +class ReportConformsToTheSchemaTest(unittest.TestCase): + """ + The point of the whole series: what this driver sends is what the shared + contract says it may send. + """ + + def test_the_default_configuration(self): + for is_scylla in (True, False): + validate_report(report_text(self, is_scylla=is_scylla)) + + def test_a_policy_that_never_reconnects(self): + """ + The schema's null arm, reached from a real configuration rather than + from no policy at all. Asserted here rather than beside the policy + mapping, since validating it needs a whole conformant document and the + report only becomes one with this group. + """ + report = validate_report(report_text( + self, reconnection_policy=ExponentialReconnectionPolicy(1.0, 2.0, max_attempts=0))) + + assert report['connection']['reconnection']['policy'] is None + + def test_a_configuration_that_avoids_every_default(self): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + report = report_text( + self, + connect_timeout=1.5, + control_connection_timeout=3, + metadata_request_timeout=4, + max_schema_agreement_wait=0, + reconnection_policy=ConstantReconnectionPolicy(0, max_attempts=9), + shard_aware_options={'disable_shardaware_port': True}, + ssl_context=context, + sockopts=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.SOL_SOCKET, socket.SO_RCVBUF, 65536), + (socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 5))], + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=TokenAwarePolicy( + RackAwareRoundRobinPolicy('dc1', 'rack1', used_hosts_per_remote_dc=1), + shuffle_replicas=False), + retry_policy=ExponentialBackoffRetryPolicy(3, 0.1, 2.0), + consistency_level=ConsistencyLevel.EACH_QUORUM, + serial_consistency_level=ConsistencyLevel.SERIAL, + request_timeout=0.0004, + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.25, 2), + )}) + + validate_report(report) + + def test_a_configuration_of_nothing_but_custom_policies(self): + class CustomLoadBalancingPolicy(RoundRobinPolicy): + pass + + class CustomRetryPolicy(RetryPolicy): + pass + + class CustomReconnectionPolicy(ReconnectionPolicy): + def new_schedule(self): + return iter(()) + + report = validate_report(report_text( + self, + reconnection_policy=CustomReconnectionPolicy(), + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=CustomLoadBalancingPolicy(), + retry_policy=CustomRetryPolicy())})) + + assert report['query']['load-balancing']['policy']['name'] == 'CustomLoadBalancingPolicy' + assert report['connection']['reconnection']['policy']['name'] == 'CustomReconnectionPolicy' + + def test_a_custom_policy_does_not_leak_its_attributes(self): + """ + The schema permits a custom policy's public attributes to be serialized + and this driver deliberately sends none of them: a policy is an + arbitrary object whose __dict__ is trivially reachable, and whatever it + holds would land in system.clients for anyone who can select from it. + """ + class CredentialCarryingPolicy(RoundRobinPolicy): + def __init__(self): + super().__init__() + self.password = 'hunter2' + self.hosts = ['10.0.0.1', '10.0.0.2'] + + report = report_text(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile( + load_balancing_policy=CredentialCarryingPolicy())}) + + assert 'hunter2' not in report + assert '10.0.0.1' not in report + assert json.loads(report)['query']['load-balancing']['policy'] == { + 'type': 'custom', 'name': 'CredentialCarryingPolicy'} + + +class UnnameableConsistencyTest(unittest.TestCase): + """ + ExecutionProfile validates serial_consistency_level but not + consistency_level, so a level the driver does not define can be configured. + """ + + def test_no_working_configuration_is_affected(self): + """ + The premise of dropping the report rather than naming something else: a + level the schema cannot name is one the driver cannot use either. + """ + with pytest.raises(Exception): + QueryMessage(query='SELECT 1', consistency_level=None).send_body(BytesIO(), 4) + + def test_the_report_is_dropped_rather_than_naming_a_level_that_is_not_used(self): + """ + consistency is a required key, so no conformant report describes such a + configuration. Naming the driver's default instead would tell an + operator that a client which cannot execute a query is querying at + LOCAL_ONE. + """ + for level in (None, 99): + options = {} + reporter(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=level) + }).add_startup_options(options, is_scylla=True) + + assert DRIVER_CONFIG_OPTION not in options, level + + def test_the_warning_names_the_setting(self): + """ + Otherwise this surfaces as a bare KeyError under a generic "unable to + build the report", which does not say which setting caused it. + """ + with self.assertLogs('cassandra.driver_config', level='WARNING') as captured: + reporter(self, execution_profiles={ + EXEC_PROFILE_DEFAULT: ExecutionProfile(consistency_level=99) + }).add_startup_options({}, is_scylla=True) + + logged = '\n'.join(r.getMessage() + (r.exc_text or '') for r in captured.records) + assert 'consistency_level is 99' in logged + + def test_recognized_levels_are_unaffected(self): + for level in ConsistencyLevel.value_to_name: + report = query_report(self, ExecutionProfile(consistency_level=level)) + assert report['defaults']['consistency'] == ConsistencyLevel.value_to_name[level] From d0eabba917f910355aa71e241ff6d15cce00f4b8 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 8/9] DRIVER-379: Cover the populated report end to end The unit tests establish that the reporter builds the right document. These establish that the document reaches the server intact and describes the client that sent it -- which is all an operator reading system.clients has. The existing {"version":1} assertion becomes a schema validation: pinning the document here would duplicate the unit tests and break on every group added to it. The round-trip test sets every setting it checks away from its default, so a report built from the wrong source, or from defaults, fails rather than happening to match. Two of these cannot be unit tests. The server-side timeout is reported only against ScyllaDB, and a unit test can only assert that for a flag it passes in itself; here the detection runs against the SUPPORTED response of an actual node. The inferred datacenter is the other: it is inferred from a host that has to exist. Co-Authored-By: Claude Opus 5 (1M context) --- .../standard/test_driver_config.py | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/tests/integration/standard/test_driver_config.py b/tests/integration/standard/test_driver_config.py index 8728c22b54..387ccba92a 100644 --- a/tests/integration/standard/test_driver_config.py +++ b/tests/integration/standard/test_driver_config.py @@ -12,12 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json +import socket import time import unittest +from cassandra import ConsistencyLevel +from cassandra.cluster import EXEC_PROFILE_DEFAULT, ExecutionProfile from cassandra.driver_config import (DRIVER_CONFIG_OPTION, DRIVER_CONFIG_SCHEMA_VERSION, SESSION_ID_OPTION) +from cassandra.policies import (ConstantReconnectionPolicy, + ConstantSpeculativeExecutionPolicy, FallthroughRetryPolicy) +from tests.driver_config_schema import validate_report from tests.integration import (TestCluster, get_client_options, use_single_node, remove_cluster, xfail_scylla_version_lt) @@ -104,6 +109,33 @@ def _assert_listed(options, count, session_id, timeout=CONNECTION_WAIT_TIMEOUT): len(options), count, session_id, timeout) +def _reported_config(cluster): + """ + Connects `cluster` and returns the configuration its control connection + reported, read back out of the clients table and validated against the + shared schema. + + Read back rather than built locally, because what the server received is the + only thing these tests can say more about than the unit tests can. The + report is validated on the way through: every configuration a test here + connects with is one this driver may really send, so each of them is a + conformance case too. + + One connection is enough to wait for: the control connection is the only one + that reports, and it is the first the cluster opens. + """ + session = cluster.connect(wait_for_all_pools=True) + session_id = str(cluster.session_id) + + options = _wait_for_connections(session, session_id, count=1) + _assert_listed(options, 1, session_id) + + reports = [o[DRIVER_CONFIG_OPTION] for o in options if DRIVER_CONFIG_OPTION in o] + assert reports, "the control connection reported no configuration" + + return validate_report(reports[0]) + + @xfail_scylla_version_lt(reason='scylladb/scylla-enterprise#5467 - system.client_options is not yet supported', scylla_version="2026.1.0") class DriverConfigReportingTest(unittest.TestCase): @@ -194,7 +226,93 @@ def test_only_the_control_connection_reports_the_driver_config(self): ("expected exactly one connection to report %s, got %d. If the control " "connection reconnected during this test, the closed one may still be " "listed with a report of its own." % (DRIVER_CONFIG_OPTION, len(reports))) - assert json.loads(reports[0]) == {'version': DRIVER_CONFIG_SCHEMA_VERSION} + + # Validated rather than compared: what the report has to be is + # whatever the shared schema allows, and pinning the document itself + # here would duplicate the unit tests and break on every group added + # to it. + report = validate_report(reports[0]) + assert report['version'] == DRIVER_CONFIG_SCHEMA_VERSION + finally: + cluster.shutdown() + + def test_the_reported_configuration_survives_the_round_trip(self): + """ + The report an operator reads out of the clients table describes the + client that wrote it. + + Everything here is set away from its default, so a report built from the + wrong source, or from defaults, fails rather than happening to match. + + TLS is not among them: the node these tests run against does not serve + it, so there is no configuration that would both connect and report a + tls group. What that group contains is settled in the unit tests. + """ + cluster = TestCluster( + connect_timeout=11, + control_connection_timeout=7, + max_schema_agreement_wait=13, + reconnection_policy=ConstantReconnectionPolicy(2.5, max_attempts=4), + sockopts=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + execution_profiles={EXEC_PROFILE_DEFAULT: ExecutionProfile( + consistency_level=ConsistencyLevel.QUORUM, + serial_consistency_level=ConsistencyLevel.LOCAL_SERIAL, + request_timeout=6, + retry_policy=FallthroughRetryPolicy(), + speculative_execution_policy=ConstantSpeculativeExecutionPolicy(0.75, 2), + )}) + try: + report = _reported_config(cluster) + + assert report['connection']['connect']['timeout-ms'] == 11000 + assert report['connection']['socket']['tcp-no-delay'] is True + assert report['connection']['reconnection']['policy'] == { + 'type': 'constant', 'delay-ms': 2500, 'max-attempts': 4} + # No TLS on this node, so the group describing it is absent. + assert 'tls' not in report['connection'] + + control_plane = report['control-plane'] + assert control_plane['queries']['system']['timeout']['client-side-ms'] == 7000 + assert control_plane['schema']['agreement']['timeout-ms'] == 13000 + + query = report['query'] + assert query['defaults']['consistency'] == 'QUORUM' + assert query['defaults']['serial-consistency'] == 'LOCAL_SERIAL' + assert query['defaults']['request']['timeout-ms'] == 6000 + assert query['retry']['policy'] == {'type': 'fallthrough'} + assert query['speculative-execution']['policy'] == { + 'type': 'constant', 'max-executions': 2, 'delay-ms': 750} + finally: + cluster.shutdown() + + def test_the_server_side_timeout_is_reported_against_scylla(self): + """ + USING TIMEOUT is a ScyllaDB extension, so this key is reported only on a + connection to a ScyllaDB node -- which is what these tests run against. + The unit tests can only assert it for a flag they pass in themselves; + this is the one place the detection itself is exercised. + """ + cluster = TestCluster(metadata_request_timeout=9) + try: + report = _reported_config(cluster) + + assert report['control-plane']['queries']['system']['timeout']['server-side-ms'] == 9000 + finally: + cluster.shutdown() + + def test_the_local_datacenter_is_reported_as_inferred(self): + """ + The driver is not told a datacenter here, so it infers one, and the + report has to say which of the two happened: an operator reading `dc` + would take it for a deliberate choice the application made. + """ + cluster = TestCluster() + try: + node_preference = _reported_config(cluster)['query']['load-balancing'].get( + 'node-preference') + + assert node_preference is not None + assert node_preference['type'] == 'dc-auto' finally: cluster.shutdown() From 29925d9e9a062c6e457157e0599e551b1d145adf Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Tue, 25 Aug 2026 14:12:53 +0200 Subject: [PATCH 9/9] DRIVER-379: Document what the configuration report describes The guide said the report carried a version and that more keys would follow. Now that they have, it describes the three groups, shows what a default Cluster reports, and links the schema where it is maintained -- an operator reading a report may well not be reading this driver's. Five things get called out, because each is a way to misread a report rather than a detail of it: that only the default execution profile is described, that a custom policy is named and never serialized, that an absent key means "does not apply" rather than "off", that the datacenter says whether it was configured or inferred, and that the query defaults are a snapshot taken before any Session exists. The example is the real output of a default Cluster, verified against it rather than written by hand. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.rst | 11 ++++ docs/scylla-specific.rst | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2967f033d6..42e8f2415c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,17 @@ Features ``Cluster(driver_config_reporting_enabled=False)``; ``SESSION_ID`` is unaffected by that setting. Reporting is best effort and never prevents a connection from being established. +* ``DRIVER_CONFIG`` now describes the configuration itself rather than only the schema + version it follows (DRIVER-379). The report covers connection settings (timeouts, + request capacity, shard awareness, socket options, reconnection policy, TLS hostname + verification), the driver's own control-plane query timeouts, and the query defaults + and policies a statement gets when it overrides none of them. It follows the JSON + schema shared with the other ScyllaDB drivers, so the same document describes a + client whichever driver wrote it. Custom policies are reported by type name only and + never by their attributes, so a policy holding a credential does not leak it into the + clients table. +* ``Cluster.sockopts`` is now materialized at construction, so a one-shot iterable is + applied to every connection the cluster opens rather than only to the first one. * Negotiate and implement the ``SCYLLA_USE_METADATA_ID`` protocol extension: prepared statements skip re-sending result metadata on EXECUTE, and the driver automatically refreshes cached metadata when the server detects a schema change (DRIVER-153) diff --git a/docs/scylla-specific.rst b/docs/scylla-specific.rst index 92df047530..9ca73b90ac 100644 --- a/docs/scylla-specific.rst +++ b/docs/scylla-specific.rst @@ -328,6 +328,141 @@ Two of the options are about the driver rather than the protocol: driver learns to describe more of its configuration, and adding one does not bump the version. + The schema is shared with the other ScyllaDB drivers, so the same document + describes a Go or C# client in the same terms. It is maintained + `upstream + `_. + +What the report describes +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three groups, each named for the part of the driver it covers: + +``connection`` + What the driver does with a single connection: the connect timeout, how many + requests one connection carries, whether pools use ScyllaDB's shard-aware + port, the socket options from ``sockopts``, the reconnection policy, and -- + when TLS is configured -- whether the server hostname is verified. + +``control-plane`` + The timeouts on the driver's own queries, the ones it runs to discover the + cluster rather than on behalf of the application: ``control_connection_timeout`` + as a client-side limit, ``metadata_request_timeout`` as the server-side one + the driver applies with ``USING TIMEOUT``, and ``max_schema_agreement_wait``. + +``query`` + What a statement gets when it overrides nothing: the default consistency, + serial consistency, page size, request timeout and timestamp behaviour, along + with the retry, load balancing and speculative execution policies. + +A report from a default ``Cluster()`` looks like this, reformatted -- what goes +on the wire has no whitespace: + +.. code:: json + + { + "version": 1, + "connection": { + "connect": {"timeout-ms": 5000}, + "requests": {"in-flight": {"max": 32767}, "orphaned": {"max": 24575}}, + "pool": {"shard-aware": {"enabled": true}}, + "socket": {"tcp-no-delay": false, "keep-alive": false, "reuse-address": false}, + "reconnection": {"policy": {"type": "exponential", "base-ms": 1000, "max-ms": 600000}} + }, + "control-plane": { + "queries": {"system": {"timeout": {"client-side-ms": 2000, "server-side-ms": 2000}}}, + "schema": {"agreement": {"timeout-ms": 10000}} + }, + "query": { + "defaults": { + "consistency": "LOCAL_ONE", + "idempotence": false, + "request": {"timeout-ms": 10000}, + "page": {"size": 5000}, + "client-timestamps": true + }, + "retry": {"policy": {"type": "standard-error-aware"}}, + "load-balancing": { + "policy": { + "type": "token-aware", + "load-distribution": "shuffle", + "fallback-to-non-preferred-nodes": false + }, + "node-preference": {"type": "dc-auto"} + } + } + } + +Five things are worth knowing when reading one: + +**Only the default execution profile is described.** The schema has a single +``query`` group, so what it reports is the profile a statement gets when it +names none -- ``EXEC_PROFILE_DEFAULT``. Policies and defaults set on other +profiles do not appear. A ``load_balancing_policy`` or ``default_retry_policy`` +passed to the ``Cluster`` constructor is folded into that same profile, so both +ways of configuring the driver read identically here. + +**A custom policy is reported by name only.** The driver never serializes a +policy object's attributes. A policy is an ordinary Python object and whatever +it happens to hold -- an auth provider, a credential, a host list -- would +otherwise land in the clients table for anyone who can read it. A policy the +driver does not recognise is reported as +``{"type": "custom", "name": "YourPolicy"}`` and nothing more, named after the +policy you configured rather than whatever sits inside it. + +The load balancing group asks a little more than that. Its built-in +``token-aware`` shape carries flags describing where a request may go, so it is +claimed only when *every* policy in the chain is one the driver can account for +-- a token-aware policy over ``DCAwareRoundRobinPolicy``, +``RackAwareRoundRobinPolicy`` or ``RoundRobinPolicy``. A chain reaching anything +else is reported as custom even with a token-aware policy wrapping it, because +the flags would otherwise assert plain token-aware routing and say nothing of +what the inner policy does. ``WhiteListRoundRobinPolicy`` and +``HostFilterPolicy`` both fall here: each confines routing to a subset of the +cluster that the flags have nowhere to record. + +``node-preference`` is reported either way -- it describes where requests go, +not which policy sends them, so a ``DCAwareRoundRobinPolicy`` or +``RackAwareRoundRobinPolicy`` reports its datacenter whether it is used on its +own, wrapped, or sitting inside a chain reported as custom. + +**Some keys are absent rather than false.** The schema uses absence to mean +"this does not apply" or "this is not knowable", so a missing key is not a +disabled setting. ``tls`` is absent when TLS is not configured; +``server-side-ms`` when the connection is not to a ScyllaDB node, since +``USING TIMEOUT`` is a ScyllaDB extension; ``speculative-execution`` when no +speculative execution is configured; and ``client-timestamps`` when a custom +``timestamp_generator`` makes it impossible to say whether the client will +assign a timestamp. + +**The datacenter says whether it was chosen or guessed.** A ``node-preference`` +of type ``dc`` carries a datacenter the application configured; ``dc-auto`` +means the driver inferred one from the first host it saw, and its ``local-dc`` +is absent until it has. The first report a cluster sends is usually the latter, +since the control connection reports before any host has come up. + +**``query.defaults`` is a cluster-level snapshot.** It is built when the control +connection is established, before any :class:`~.Session` exists. Under execution +profiles the default profile is what it describes. In legacy configuration mode +the consistency, the serial consistency and the request timeout come from the +``Session`` instead -- ``Session.default_consistency_level``, +``default_serial_consistency_level`` and ``default_timeout``, which is where a +legacy request reads them -- and ``default_fetch_size`` and +``use_client_timestamp`` come from there in both modes. + +All five live on the ``Session``, and no session exists yet when the report is +built, so what is reported is the default every session created from the cluster +will start with. Setting one of them on a session after ``connect()`` does not +change what was reported, and is not picked up by a report a later control +connection builds either. + +Values the driver has no way to express under this schema version -- +``idle_heartbeat_interval``, the protocol version, compression, and non-default +execution profiles -- are left out rather than approximated. + +Reading and controlling the options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + .. code:: python from cassandra.cluster import Cluster