diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 068be2e048..42e8f2415c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,12 +14,38 @@ 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) 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. +* ``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/cluster.py b/cassandra/cluster.py index 7260bd08b6..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 @@ -1520,7 +1523,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 af95891a3b..ca1390c79f 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) @@ -1563,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..b6b8fa2f4d 100644 --- a/cassandra/driver_config.py +++ b/cassandra/driver_config.py @@ -18,8 +18,30 @@ incident can inspect the settings of a client without access to its host. """ +import datetime import json import logging +import math +import operator +import socket +import struct +import weakref +from collections import namedtuple +from itertools import repeat + +from cassandra import ConsistencyLevel +from cassandra.policies import (ConstantReconnectionPolicy, + ConstantSpeculativeExecutionPolicy, + DCAwareRoundRobinPolicy, + DowngradingConsistencyRetryPolicy, + ExponentialBackoffRetryPolicy, + ExponentialReconnectionPolicy, + FallthroughRetryPolicy, NeverRetryPolicy, + NoSpeculativeExecutionPolicy, + DefaultLoadBalancingPolicy, + RackAwareRoundRobinPolicy, RetryPolicy, + RoundRobinPolicy, TokenAwarePolicy) +from cassandra.timestamps import MonotonicTimestampGenerator log = logging.getLogger(__name__) @@ -56,20 +78,874 @@ ``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 _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`` + 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 + + +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), + ('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 _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 -- because it admits no attempt at all, or because it is + not a limit any integer can express. + """ + if limit is None: + return None + attempts = _integer_ceiling(limit) + if attempts is None: + 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__} + + +_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 @@ -81,10 +957,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 +983,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,24 +1005,300 @@ 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): + """ + Adds the configuration groups themselves to the report. + """ + 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): + """ + 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`. """ - Extension point for adding the configuration groups themselves to the - report. Empty for now. + 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 _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, + 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. + + 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/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/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 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/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() 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_cluster.py b/tests/unit/test_cluster.py index 35dc354465..f6f666333f 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -319,6 +319,34 @@ 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 + 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 +403,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 5962db1189..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 @@ -414,6 +413,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 @@ -487,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}, @@ -496,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: @@ -585,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): """ @@ -596,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..05f07a8069 100644 --- a/tests/unit/test_driver_config.py +++ b/tests/unit/test_driver_config.py @@ -12,40 +12,100 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime +import gc import json +from decimal import Decimal +from fractions import Fraction +from itertools import islice +import socket +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 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) -from tests.unit.utils import ThrowingReporter + DRIVER_CONFIG_SCHEMA_VERSION, MAX_DRIVER_CONFIG_LENGTH, + _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, 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 -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(test, **cluster_kwargs): + """ + The reporter of a real Cluster, which stays alive for the test. + + 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(**cluster_kwargs) + test.addCleanup(cluster.shutdown) + return cluster._driver_config_reporter + + +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=is_scylla) + + class DriverConfigReporterTest(unittest.TestCase): def test_reports_the_schema_version(self): options = {} - DriverConfigReporter().add_startup_options(options) + 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): """ @@ -54,19 +114,20 @@ def test_report_is_compact_json(self): """ options = {} - DriverConfigReporter().add_startup_options(options) + 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 = {} - DriverConfigReporter().add_startup_options(options) + 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" @@ -76,7 +137,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 +148,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 +160,1956 @@ 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(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} + + +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 + + +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] 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/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): 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 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" },