diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a6321adbef..df1e40d3f0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,7 +38,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: ref: ${{ inputs.ref }} persist-credentials: false diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index 99fa5a9604..2dac08e260 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout pymongo - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -105,7 +105,7 @@ jobs: name: Make SDist runs-on: macos-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 3cb1c3d206..0b9160569f 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index ce2ba071ea..7dc850fc36 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -22,7 +22,7 @@ jobs: static: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -67,7 +67,7 @@ jobs: name: Python ${{ matrix.python-version }}-${{ matrix.os }} steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -89,7 +89,7 @@ jobs: name: Coverage steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -117,7 +117,7 @@ jobs: runs-on: ubuntu-latest name: DocTest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -142,7 +142,7 @@ jobs: name: Docs Checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -164,7 +164,7 @@ jobs: matrix: python: ["3.10", "3.11"] steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -185,7 +185,7 @@ jobs: runs-on: ubuntu-latest name: Integration Tests steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv @@ -215,7 +215,7 @@ jobs: runs-on: ubuntu-latest name: "Make an sdist" steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - uses: actions/setup-python@v6 @@ -275,7 +275,7 @@ jobs: runs-on: ubuntu-latest name: Test minimum dependencies and Python steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Install uv diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 9e299ef1f9..57b1d3a93f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -14,8 +14,8 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 with: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 diff --git a/AGENTS.md b/AGENTS.md index 8417c2cd3e..ac1d33ec59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,6 @@ Official Python driver for MongoDB. ## Tech Stack -- **Build/env:** `hatch` — use `hatch run test:test` and `hatch run test:test-async`, not `pytest` directly - **Task runner:** `just` (Justfile at repo root) - **Linter/formatter:** `ruff` via pre-commit - **Type checker:** `mypy` (strict) @@ -13,37 +12,31 @@ Official Python driver for MongoDB. > **Never** run `pip install bson`. PyMongo ships its own `bson`; the PyPI `bson` package silently breaks things. ## Project Structure -- `pymongo/synchronous/` — sync driver (MongoClient, Collection, Database, etc.) generated from `pymongo/asynchronous/` using unasync -- `pymongo/asynchronous/` — async API, mirrors `pymongo/synchronous/` +- `pymongo/asynchronous/` — async driver (AsyncMongoClient, AsyncCollection, etc.) +- `pymongo/synchronous/` — sync driver (MongoClient, Collection, etc.) generated from `pymongo/asynchronous/` using `just synchro` - `bson/` — BSON implementation - `gridfs/` — GridFS API -- `test/` — sync test suite -- `test/asynchronous/` — async test suite, mirrors `test/` +- `test/asynchronous/` — async test suite +- `test/` — sync test suite (generated from `test/asynchronous/` by `just synchro`) and shared test suite - `test/unified_format/` — cross-driver spec tests (Unified Test Format) -- `tools/convert_test_to_async.py` — generates async test stubs from sync tests + +Do not edit files in `pymongo/synchronous/` or mirrored files in `test/` directly: they are generated by `just synchro` from `pymongo/asynchronous/` and `test/asynchronous/`. +A file in `test/` is mirrored if a file of the same name exists in `test/asynchronous/`. ## Commands ```bash -just install # set up venv + pre-commit hook -just typing-mypy # type check -just run lint-manual # ruff linter - -hatch run test:test # run sync tests -hatch run test:test-async # run async tests - -MONGODB_VERSION=8.0 just run-server # start a local MongoDB server +just install # set up venv + pre-commit hooks +just typing # type check +just lint-manual # pre-commit linting and synchro +just synchro # generate synchronous driver and mirrored tests +just test # run all tests +just test test/asynchronous # run async tests +MONGODB_VERSION=8.0 just run-server # start a local MongoDB 8.0 server ``` ## Testing Tests require a live MongoDB server (`just run-server` above). -Never modify pymongo/synchronous code first; update the async counterpart and run our `tools/synchro.py` script: -1. Make changes in `pymongo/asynchronous` or top-level `pymongo/` files and `test/`. -2. Run `tools/convert_test_to_async.py` on the sync test file to generate a starting-point async version. -3. Manually complete the async version in `test/asynchronous/`. - -Do not edit files in `pymongo/synchronous/` or mirrored files in `test/` directly — they are auto-generated by `tools/synchro.py` from `pymongo/asynchronous/` and `test/asynchronous/`. Only edit them if the change includes a `_IS_SYNC` statement. - Async functions must not call blocking I/O. New features need integration tests. Bug fixes need regression tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 773c9ec0d8..cddcee0989 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -529,14 +529,6 @@ Python errors, such as missing imports, incorrect syntax, or other fatal typos. run `pre-commit run --all-files --hook-stage manual ruff` and fix all reported errors before running the `synchro` hook again. -## Converting a test to async - -The `tools/convert_test_to_async.py` script takes in an existing synchronous test file and outputs a -partially-converted asynchronous version of the same name to the `test/asynchronous` directory. -Use this generated file as a starting point for the completed conversion. - -The script is used like so: `python tools/convert_test_to_async.py [test_file.py]` - ## CPU profiling To profile a test script and generate a flame graph, follow these steps: diff --git a/bson/codec_options.py b/bson/codec_options.py index f641efefdf..848f672f54 100644 --- a/bson/codec_options.py +++ b/bson/codec_options.py @@ -377,14 +377,16 @@ def __new__( datetime_conversion: Optional[DatetimeConversion] = DatetimeConversion.DATETIME, ) -> CodecOptions: doc_class = document_class or dict - # issubclass can raise TypeError for generic aliases like SON[str, Any]. - # In that case we can use the base class for the comparison. - is_mapping = False + # Generic aliases like SON[str, Any] or dict[str, Any] aren't classes, so + # resolve to their origin before the subclass check. Whether issubclass() + # raises TypeError or just returns False for such aliases is inconsistent + # across Python implementations (e.g. PyPy vs CPython), so check the + # origin proactively instead of relying on catching the error. + check_class = getattr(doc_class, "__origin__", doc_class) try: - is_mapping = issubclass(doc_class, _MutableMapping) + is_mapping = issubclass(check_class, _MutableMapping) except TypeError: - if hasattr(doc_class, "__origin__"): - is_mapping = issubclass(doc_class.__origin__, _MutableMapping) + is_mapping = False if not (is_mapping or _raw_document_class(doc_class)): raise TypeError( "document_class must be dict, bson.son.SON, " diff --git a/justfile b/justfile index 55ff9e74dd..df678cdbac 100644 --- a/justfile +++ b/justfile @@ -64,6 +64,11 @@ lint *args="": && resync lint-manual *args="": && resync uvx pre-commit run --all-files --hook-stage manual {{args}} +# Run synchro +[group('lint')] +synchro: && resync + uvx pre-commit run --all-files synchro + # Run pytest (e.g. just test test/test_uri_parser.py) [group('test')] test *args="-v --durations=5 --maxfail=10": && resync diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index 471a013eb9..14bb866316 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -18,16 +18,41 @@ import datetime import logging +import queue +import time from collections.abc import MutableMapping from typing import TYPE_CHECKING, Any, Optional -from pymongo.logger import _COMMAND_LOGGER, _CommandStatusMessage, _debug_log +from pymongo.logger import ( + _COMMAND_LOGGER, + _CONNECTION_LOGGER, + _SDAM_LOGGER, + _SERVER_SELECTION_LOGGER, + _CommandStatusMessage, + _ConnectionStatusMessage, + _debug_log, + _SDAMStatusMessage, + _ServerSelectionStatusMessage, + _verbose_connection_error_reason, +) from pymongo.pool_shared import _ConnectionTelemetryInfo if TYPE_CHECKING: from bson.objectid import ObjectId + from pymongo.hello import Hello from pymongo.monitoring import _EventListeners - from pymongo.typings import _DocumentOut + from pymongo.server_description import ServerDescription + from pymongo.topology_description import TopologyDescription + from pymongo.typings import _Address, _DocumentOut + + +def _monotonic_duration(start: float) -> float: + """Return the duration since the given start time. + + Accounts for buggy platforms where time.monotonic() is not monotonic. + See PYTHON-4600. + """ + return max(0.0, time.monotonic() - start) class _CommandTelemetry: @@ -183,3 +208,537 @@ def failed( service_id=self._conn.service_id, database_name=self._dbname, ) + + +class _CmapTelemetry: + """Combines CMAP structured logging and APM event publishing for pool and connection events.""" + + __slots__ = ( + "_address", + "_client_id", + "_listeners", + "_log", + "_publish", + ) + + def __init__( + self, + client_id: Optional[ObjectId], + address: _Address, + listeners: Optional[_EventListeners], + publish: bool, + log: bool, + ) -> None: + self._client_id = client_id + self._address = address + self._listeners = listeners + self._publish = publish + self._log = log + + @property + def _should_publish(self) -> bool: + """Computed per-call because listener registration can change while the pool is open.""" + return self._publish and self._listeners is not None and self._listeners.enabled_for_cmap + + @property + def _should_log(self) -> bool: + """Computed per-call because logging level can be reconfigured at runtime.""" + return self._log and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG) + + def _emit_log(self, message: _ConnectionStatusMessage, **extra: Any) -> None: + _debug_log( + _CONNECTION_LOGGER, + message=message, + clientId=self._client_id, + serverHost=self._address[0], + serverPort=self._address[1], + **extra, + ) + + def pool_created(self, non_default_options: dict[str, Any]) -> None: + """Emit the pool created log entry and APM event.""" + # Log before publishing to prevent potential listener preemption in tests. + if self._should_log: + self._emit_log(_ConnectionStatusMessage.POOL_CREATED, **non_default_options) + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_pool_created(self._address, non_default_options) + + def pool_ready(self) -> None: + """Emit the pool ready log entry and APM event.""" + # Log before publishing to prevent potential listener preemption in tests. + if self._should_log: + self._emit_log(_ConnectionStatusMessage.POOL_READY) + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_pool_ready(self._address) + + def pool_cleared(self, service_id: Optional[ObjectId], interrupt_connections: bool) -> None: + """Emit the pool cleared log entry and APM event.""" + # Log before publishing to prevent potential listener preemption in tests. + if self._should_log: + self._emit_log(_ConnectionStatusMessage.POOL_CLEARED, serviceId=service_id) + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_pool_cleared( + self._address, + service_id=service_id, + interrupt_connections=interrupt_connections, + ) + + def pool_closed(self) -> None: + """Emit the pool closed log entry and APM event.""" + # Log before publishing to prevent potential listener preemption in tests. + if self._should_log: + self._emit_log(_ConnectionStatusMessage.POOL_CLOSED) + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_pool_closed(self._address) + + def connection_created(self, conn_id: int) -> None: + """Emit the connection created log entry and APM event.""" + # Log before publishing to prevent potential listener preemption in tests. + if self._should_log: + self._emit_log(_ConnectionStatusMessage.CONN_CREATED, driverConnectionId=conn_id) + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_connection_created(self._address, conn_id) + + def connection_ready(self, conn_id: int, creation_time: float) -> None: + """Emit the connection ready log entry and APM event.""" + should_log = self._should_log + should_publish = self._should_publish + if not should_log and not should_publish: + return + duration = _monotonic_duration(creation_time) + # Log before publishing to prevent potential listener preemption in tests. + if should_log: + self._emit_log( + _ConnectionStatusMessage.CONN_READY, + driverConnectionId=conn_id, + durationMS=duration * 1000, + ) + if should_publish: + assert self._listeners is not None + self._listeners.publish_connection_ready(self._address, conn_id, duration) + + def connection_closed(self, conn_id: int, reason: str) -> None: + """Emit the connection closed log entry and APM event.""" + should_log = self._should_log + should_publish = self._should_publish + if should_publish: + assert self._listeners is not None + self._listeners.publish_connection_closed(self._address, conn_id, reason) + if should_log: + self._emit_log( + _ConnectionStatusMessage.CONN_CLOSED, + driverConnectionId=conn_id, + reason=_verbose_connection_error_reason(reason), + error=reason, + ) + + def checkout_started(self) -> float: + """Emit the checkout started event/log and return the start time for duration tracking.""" + start = time.monotonic() + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_connection_check_out_started(self._address) + if self._should_log: + self._emit_log(_ConnectionStatusMessage.CHECKOUT_STARTED) + return start + + def checkout_succeeded(self, conn_id: int, start: float) -> None: + """Emit the checkout succeeded log entry and APM event.""" + should_log = self._should_log + should_publish = self._should_publish + if not should_log and not should_publish: + return + duration = _monotonic_duration(start) + if should_publish: + assert self._listeners is not None + self._listeners.publish_connection_checked_out(self._address, conn_id, duration) + if should_log: + self._emit_log( + _ConnectionStatusMessage.CHECKOUT_SUCCEEDED, + driverConnectionId=conn_id, + durationMS=duration * 1000, + ) + + def checkout_failed(self, reason: str, error: str, start: float) -> None: + """Emit the checkout failed log entry and APM event.""" + should_log = self._should_log + should_publish = self._should_publish + if not should_log and not should_publish: + return + duration = _monotonic_duration(start) + if should_publish: + assert self._listeners is not None + self._listeners.publish_connection_check_out_failed(self._address, error, duration) + if should_log: + self._emit_log( + _ConnectionStatusMessage.CHECKOUT_FAILED, + reason=reason, + error=error, + durationMS=duration * 1000, + ) + + def checked_in(self, conn_id: int) -> None: + """Emit the connection checked-in log entry and APM event.""" + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_connection_checked_in(self._address, conn_id) + if self._should_log: + self._emit_log(_ConnectionStatusMessage.CHECKEDIN, driverConnectionId=conn_id) + + +class _HeartbeatTelemetry: + """Combines SDAM structured logging and APM event publishing for server heartbeats. + + The APM started event is published before connection checkout (no conn_id yet); + the log entry for started is emitted after checkout once the conn_id is known. + Call :meth:`started` first, then :meth:`emit_started_log` inside the checkout + context, then :meth:`succeeded` or :meth:`failed` when the outcome is known. + """ + + __slots__ = ( + "_address", + "_listeners", + "_should_log", + "_should_publish", + "_start", + "_topology_id", + ) + + def __init__( + self, + topology_id: ObjectId, + address: _Address, + listeners: Optional[_EventListeners], + ) -> None: + self._topology_id = topology_id + self._address = address + self._listeners = listeners + # Cached at construction: this object is short-lived (one heartbeat check) so + # listener registration and logging level are stable for its lifetime. + self._should_publish = listeners is not None and listeners.enabled_for_server_heartbeat + self._should_log = _SDAM_LOGGER.isEnabledFor(logging.DEBUG) + self._start: float = 0.0 + + def _emit_log(self, message: _SDAMStatusMessage, awaited: bool, **extra: Any) -> None: + _debug_log( + _SDAM_LOGGER, + message=message, + topologyId=self._topology_id, + serverHost=self._address[0], + serverPort=self._address[1], + awaited=awaited, + **extra, + ) + + def started(self, awaited: bool) -> None: + """Publish the APM heartbeat-started event (before connection checkout).""" + if self._should_publish or self._should_log: + self._start = time.monotonic() + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_server_heartbeat_started(self._address, awaited) + + def emit_started_log(self, conn_id: int, server_conn_id: Optional[int], awaited: bool) -> None: + """Emit the log entry for heartbeat started (after connection checkout).""" + if self._should_log: + self._emit_log( + _SDAMStatusMessage.HEARTBEAT_START, + awaited=awaited, + driverConnectionId=conn_id, + serverConnectionId=server_conn_id, + ) + + def succeeded( + self, + round_trip_time: float, + response: Hello[Any], + conn_id: int, + server_conn_id: Optional[int], + ) -> None: + """Emit the SUCCEEDED log entry and APM event.""" + if self._should_publish: + assert self._listeners is not None + self._listeners.publish_server_heartbeat_succeeded( + self._address, round_trip_time, response, response.awaitable + ) + if self._should_log: + self._emit_log( + _SDAMStatusMessage.HEARTBEAT_SUCCESS, + awaited=response.awaitable, + driverConnectionId=conn_id, + serverConnectionId=server_conn_id, + durationMS=round_trip_time * 1000, + reply=response.document, + ) + + def failed(self, error: Exception, conn_id: Optional[int], awaited: bool) -> None: + """Emit the FAILED log entry and APM event.""" + should_publish = self._should_publish + should_log = self._should_log + if not should_publish and not should_log: + return + duration = _monotonic_duration(self._start) + if should_publish: + assert self._listeners is not None + self._listeners.publish_server_heartbeat_failed(self._address, duration, error, awaited) + if should_log: + self._emit_log( + _SDAMStatusMessage.HEARTBEAT_FAIL, + awaited=awaited, + durationMS=duration * 1000, + failure=error, + driverConnectionId=conn_id, + ) + + +class _SdamTelemetry: + """Combines SDAM structured logging and APM event publishing for topology and server events. + + Topology events are queued for asynchronous delivery; log entries are emitted inline. + """ + + __slots__ = ("_events", "_listeners", "_topology_id") + + def __init__( + self, + topology_id: ObjectId, + listeners: Optional[_EventListeners], + events: Optional[queue.Queue[Any]], + ) -> None: + self._topology_id = topology_id + self._listeners = listeners + self._events = events + + @property + def _publish_server(self) -> bool: + """Computed per-call because listener registration can change while the topology is open.""" + return self._listeners is not None and self._listeners.enabled_for_server + + @property + def _publish_tp(self) -> bool: + """Computed per-call because listener registration can change while the topology is open.""" + return self._listeners is not None and self._listeners.enabled_for_topology + + @property + def _should_log(self) -> bool: + """Computed per-call because logging level can be reconfigured at runtime.""" + return _SDAM_LOGGER.isEnabledFor(logging.DEBUG) + + def _enqueue(self, fn: Any, args: tuple[Any, ...]) -> None: + if self._events is not None: + self._events.put((fn, args)) + + def _emit_log(self, message: _SDAMStatusMessage, **extra: Any) -> None: + _debug_log( + _SDAM_LOGGER, + message=message, + topologyId=self._topology_id, + **extra, + ) + + def topology_opened(self) -> None: + """Emit the topology opened log entry and APM event.""" + if self._should_log: + self._emit_log(_SDAMStatusMessage.START_TOPOLOGY) + if self._publish_tp: + assert self._listeners is not None + self._enqueue(self._listeners.publish_topology_opened, (self._topology_id,)) + + def topology_description_changed( + self, old_td: TopologyDescription, new_td: TopologyDescription + ) -> None: + """Emit the topology description changed APM event and log entry.""" + if self._publish_tp: + assert self._listeners is not None + self._enqueue( + self._listeners.publish_topology_description_changed, + (old_td, new_td, self._topology_id), + ) + if self._should_log: + self._emit_log( + _SDAMStatusMessage.TOPOLOGY_CHANGE, + previousDescription=repr(old_td), + newDescription=repr(new_td), + ) + + def topology_closed(self, old_td: TopologyDescription, new_td: TopologyDescription) -> None: + """Emit APM and log events for topology description change + topology closed.""" + if self._publish_tp: + assert self._listeners is not None + self._enqueue( + self._listeners.publish_topology_description_changed, + (old_td, new_td, self._topology_id), + ) + self._enqueue(self._listeners.publish_topology_closed, (self._topology_id,)) + if self._should_log: + self._emit_log( + _SDAMStatusMessage.TOPOLOGY_CHANGE, + previousDescription=repr(old_td), + newDescription=repr(new_td), + ) + self._emit_log(_SDAMStatusMessage.STOP_TOPOLOGY) + + def server_opened(self, address: _Address) -> None: + """Emit the server opened log entry and APM event.""" + if self._publish_server: + assert self._listeners is not None + self._enqueue(self._listeners.publish_server_opened, (address, self._topology_id)) + if self._should_log: + self._emit_log( + _SDAMStatusMessage.START_SERVER, + serverHost=address[0], + serverPort=address[1], + ) + + def server_description_changed( + self, sd_old: ServerDescription, sd_new: ServerDescription, address: _Address + ) -> None: + """Emit the server description changed APM event.""" + if self._publish_server: + assert self._listeners is not None + self._enqueue( + self._listeners.publish_server_description_changed, + (sd_old, sd_new, address, self._topology_id), + ) + + def server_closed(self, address: _Address) -> None: + """Emit the server closed log entry and APM event.""" + if self._publish_server: + assert self._listeners is not None + self._enqueue(self._listeners.publish_server_closed, (address, self._topology_id)) + if self._should_log: + self._emit_log( + _SDAMStatusMessage.STOP_SERVER, + serverHost=address[0], + serverPort=address[1], + ) + + +class _ServerSelectionTelemetry: + """Structured logging for server selection events. + + The server selection spec defines only log entries, not APM events, so this + class has no publish methods. + + Construct once per :meth:`select_server` call. + """ + + __slots__ = ( + "_operation", + "_operation_id", + "_selector", + "_should_log", + "_topology_description", + "_topology_id", + ) + + def __init__( + self, + topology_id: ObjectId, + selector: Any, + operation: str, + operation_id: Optional[int], + topology_description: TopologyDescription, + ) -> None: + self._topology_id = topology_id + self._selector = selector + self._operation = operation + self._operation_id = operation_id + self._topology_description = topology_description + # Cached at construction: this object is short-lived (one select_server call) so + # logging level is stable for its lifetime. + self._should_log = _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG) + + def _emit_log( + self, + message: _ServerSelectionStatusMessage, + topology_description: TopologyDescription, + **extra: Any, + ) -> None: + _debug_log( + _SERVER_SELECTION_LOGGER, + message=message, + clientId=self._topology_id, + selector=self._selector, + operation=self._operation, + operationId=self._operation_id, + topologyDescription=topology_description, + **extra, + ) + + def started(self) -> None: + """Emit the server selection STARTED log entry.""" + if self._should_log: + self._emit_log(_ServerSelectionStatusMessage.STARTED, self._topology_description) + + def waiting(self, remaining_time_ms: int) -> None: + """Emit the server selection WAITING log entry.""" + if self._should_log: + self._emit_log( + _ServerSelectionStatusMessage.WAITING, + self._topology_description, + remainingTimeMS=remaining_time_ms, + ) + + def failed(self, failure: str, topology_description: TopologyDescription) -> None: + """Emit the server selection FAILED log entry with the current topology description.""" + if self._should_log: + self._emit_log( + _ServerSelectionStatusMessage.FAILED, + topology_description, + failure=failure, + ) + + +def log_server_selection_succeeded( + topology_id: ObjectId, + selector: Any, + operation: str, + operation_id: Optional[int], + topology_description: TopologyDescription, + server_host: str, + server_port: Optional[int], +) -> None: + """Emit the server selection SUCCEEDED log entry.""" + if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): + _debug_log( + _SERVER_SELECTION_LOGGER, + message=_ServerSelectionStatusMessage.SUCCEEDED, + clientId=topology_id, + selector=selector, + operation=operation, + operationId=operation_id, + topologyDescription=topology_description, + serverHost=server_host, + serverPort=server_port, + ) + + +def log_srv_monitor_failure(failure: Exception) -> None: + """Emit a log entry when the SRV monitor fails to poll DNS records.""" + if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): + _debug_log(_SDAM_LOGGER, message="SRV monitor check failed", failure=repr(failure)) + + +def log_command_retry( + topology_id: Optional[ObjectId], + command_name: str, + operation_id: Optional[int], + attempt_number: int, + is_write: bool, +) -> None: + """Emit a command-retry log entry.""" + if _COMMAND_LOGGER.isEnabledFor(logging.DEBUG): + op = "write" if is_write else "read" + _debug_log( + _COMMAND_LOGGER, + message=f"Retrying {op} attempt number {attempt_number}", + clientId=topology_id, + commandName=command_name, + operationId=operation_id, + ) diff --git a/pymongo/asynchronous/auth_oidc.py b/pymongo/asynchronous/auth_oidc.py index 85df19a514..349a1b437d 100644 --- a/pymongo/asynchronous/auth_oidc.py +++ b/pymongo/asynchronous/auth_oidc.py @@ -51,9 +51,6 @@ def _get_authenticator( credentials: MongoCredential, address: tuple[str, int] ) -> _OIDCAuthenticator: - if credentials.cache.data: - return credentials.cache.data - # Extract values. principal_name = credentials.username properties = credentials.mechanism_properties @@ -72,6 +69,9 @@ def _get_authenticator( f"Refusing to connect to {address[0]}, which is not in authOIDCAllowedHosts: {allowed_hosts}" ) + if credentials.cache.data: + return credentials.cache.data + # Get or create the cache data. credentials.cache.data = _OIDCAuthenticator(username=principal_name, properties=properties) return credentials.cache.data diff --git a/pymongo/asynchronous/command_cursor.py b/pymongo/asynchronous/command_cursor.py index fb85755362..71404281a4 100644 --- a/pymongo/asynchronous/command_cursor.py +++ b/pymongo/asynchronous/command_cursor.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""CommandCursor class to iterate over command results.""" +"""AsyncCommandCursor class to iterate over command results.""" from __future__ import annotations @@ -42,7 +42,18 @@ class AsyncCommandCursor(_AsyncCursorBase[_DocumentType]): - """An asynchronous cursor / iterator over command cursors.""" + """An asynchronous cursor / iterator over command cursors. + Used by :meth:`~pymongo.asynchronous.collection.AsyncCollection.aggregate`, + :meth:`~pymongo.asynchronous.database.AsyncDatabase.aggregate`, + :meth:`~pymongo.asynchronous.collection.AsyncCollection.list_indexes`, + :meth:`~pymongo.asynchronous.collection.AsyncCollection.list_search_indexes` + :meth:`~pymongo.asynchronous.database.AsyncDatabase.list_collections`, + :meth:`~pymongo.asynchronous.database.AsyncDatabase.cursor_command`, + and :meth:`~pymongo.asynchronous.mongo_client.AsyncMongoClient.list_databases` + to iterate MongoDB command results. + + Should not be called directly by application developers. + """ _getmore_class = _GetMore @@ -162,7 +173,7 @@ async def _send_message(self, operation: _GetMore) -> None: client = self._collection.database.client try: response = await client._run_operation( - operation, self._unpack_response, address=self._address + operation, self._run_with_conn, address=self._address ) except OperationFailure as exc: if exc.code in _CURSOR_CLOSED_ERRORS: diff --git a/pymongo/asynchronous/command_runner.py b/pymongo/asynchronous/command_runner.py index 1f32cc744e..3fc048fc39 100644 --- a/pymongo/asynchronous/command_runner.py +++ b/pymongo/asynchronous/command_runner.py @@ -24,7 +24,7 @@ batches. Pre-encrypted, so decryption is skipped. Callers: ``bulk.py``, ``client_bulk.py``. - :func:`run_cursor_command` — cursor ``find``/``getMore`` operations with - exhaust-cursor handling. Caller: ``server.py``. + exhaust-cursor handling. Caller: ``cursor_base.py``. :func:`_run_command` owns the entire shared skeleton: command logging, APM event publishing, ``send``/``receive``, ``$clusterTime`` gossip, diff --git a/pymongo/asynchronous/cursor.py b/pymongo/asynchronous/cursor.py index 4a4fdf43ff..51c00a0b36 100644 --- a/pymongo/asynchronous/cursor.py +++ b/pymongo/asynchronous/cursor.py @@ -97,9 +97,9 @@ def __init__( let: Optional[bool] = None, ) -> None: """Create a new cursor. + Used by :meth:`~pymongo.asynchronous.collection.AsyncCollection.find` to iterate over MongoDB query results. - Should not be called directly by application developers - see - :meth:`~pymongo.asynchronous.collection.AsyncCollection.find` instead. + Should not be called directly by application developers. .. seealso:: The MongoDB documentation on `cursors `_. """ @@ -980,7 +980,7 @@ async def _send_message(self, operation: Union[_Query, _GetMore]) -> None: try: response = await client._run_operation( - operation, self._unpack_response, address=self._address + operation, self._run_with_conn, address=self._address ) except OperationFailure as exc: if exc.code in _CURSOR_CLOSED_ERRORS or self._exhaust: diff --git a/pymongo/asynchronous/cursor_base.py b/pymongo/asynchronous/cursor_base.py index ce3114684a..e8ac4c3139 100644 --- a/pymongo/asynchronous/cursor_base.py +++ b/pymongo/asynchronous/cursor_base.py @@ -17,20 +17,40 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Any, Optional +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Optional, Union from pymongo import _csot -from pymongo.cursor_shared import _AgnosticCursorBase +from pymongo.asynchronous.command_runner import run_cursor_command +from pymongo.asynchronous.helpers import _handle_reauth +from pymongo.cursor_shared import _CURSOR_DOC_FIELDS, _AgnosticCursorBase, _split_message from pymongo.lock import _async_create_lock -from pymongo.typings import _DocumentType +from pymongo.message import _GetMore, _OpMsg, _Query +from pymongo.response import PinnedResponse, Response +from pymongo.typings import _DocumentOut, _DocumentType if TYPE_CHECKING: from pymongo.asynchronous.client_session import AsyncClientSession from pymongo.asynchronous.pool import AsyncConnection + from pymongo.read_preferences import _ServerMode _IS_SYNC = False +async def _operation_to_command( + operation: Union[_Query, _GetMore], + conn: AsyncConnection, + apply_timeout: bool, +) -> tuple[dict[str, Any], str]: + cmd, db = operation.as_command(conn, apply_timeout) + if operation.client._encrypter and not operation.client._encrypter._bypass_auto_encryption: + cmd = await operation.client._encrypter.encrypt( # type: ignore[misc, assignment] + operation.db, cmd, operation.codec_options + ) + operation.update_command(cmd) + return cmd, db + + class _ConnectionManager: """Used with exhaust cursors to ensure the connection is returned.""" @@ -66,6 +86,87 @@ def session(self) -> Optional[AsyncClientSession]: async def _next_batch(self, result: list, total: Optional[int] = None) -> bool: # type: ignore[type-arg] ... + @abstractmethod + def _unpack_response( + self, + response: _OpMsg, + cursor_id: Optional[int], + codec_options: Any, + user_fields: Optional[Mapping[str, Any]] = None, + legacy_response: bool = False, + ) -> Sequence[_DocumentOut]: ... + + @_handle_reauth + async def _run_with_conn( + self, + conn: AsyncConnection, + operation: Union[_Query, _GetMore], + read_preference: _ServerMode, + ) -> Response: + """Execute a cursor operation on the given connection and return a Response. + + :param conn: An AsyncConnection instance. + :param operation: A _Query or _GetMore object. + :param read_preference: The read preference to use. + """ + client = self._collection.database.client + use_cmd = operation.use_command(conn) + more_to_come = bool(operation.conn_mgr and operation.conn_mgr.more_to_come) + cmd, dbn = await _operation_to_command(operation, conn, use_cmd) + if more_to_come: + request_id, data, max_doc_size = 0, b"", 0 + else: + message = operation.get_message(read_preference, conn, use_cmd) + request_id, data, max_doc_size = _split_message(message) + user_fields = _CURSOR_DOC_FIELDS if use_cmd else None + docs, reply, duration = await run_cursor_command( + conn, + cmd, + dbn, + request_id, + data, + client=client, + session=operation.session, # type: ignore[arg-type] + listeners=client._event_listeners, + codec_options=operation.codec_options, + user_fields=user_fields, + command_name=operation.name, + pool_opts=conn.opts, + max_doc_size=max_doc_size, + more_to_come=more_to_come, + unpack_res=self._unpack_response, + cursor_id=operation.cursor_id, + ) + assert reply is not None + if client._should_pin_cursor(operation.session) or operation.exhaust: # type: ignore[arg-type] + conn.pin_cursor() + if isinstance(reply, _OpMsg): + # In OP_MSG, the server keeps sending only if the more_to_come flag is set. + more_to_come = reply.more_to_come + else: + # In OP_REPLY, the server keeps sending until cursor_id is 0. + more_to_come = bool(operation.exhaust and reply.cursor_id) + if operation.conn_mgr: + operation.conn_mgr.update_exhaust(more_to_come) + return PinnedResponse( + data=reply, + address=conn.address, + conn=conn, + duration=duration, + request_id=request_id, + from_command=use_cmd, + docs=docs, # type: ignore[arg-type] + more_to_come=more_to_come, + ) + return Response( + data=reply, + address=conn.address, + duration=duration, + request_id=request_id, + from_command=use_cmd, + docs=docs, # type: ignore[arg-type] + ) + async def _die_lock(self) -> None: """Closes this cursor.""" try: diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index a558f96356..2a53462f59 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -57,6 +57,7 @@ from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, TypeRegistry from bson.timestamp import Timestamp from pymongo import _csot, common, helpers_shared, periodic_executor +from pymongo._telemetry import log_command_retry from pymongo.asynchronous import client_session, database, uri_parser from pymongo.asynchronous.change_stream import AsyncChangeStream, AsyncClusterChangeStream from pymongo.asynchronous.client_bulk import _AsyncClientBulk @@ -90,8 +91,6 @@ ) from pymongo.logger import ( _CLIENT_LOGGER, - _COMMAND_LOGGER, - _debug_log, _log_client_error, _log_or_warn, ) @@ -1911,13 +1910,14 @@ async def _conn_for_reads( async def _run_operation( self, operation: Union[_Query, _GetMore], - unpack_res: Callable, # type: ignore[type-arg] + run_with_conn: Callable, # type: ignore[type-arg] address: Optional[_Address] = None, ) -> Response: """Run a _Query/_GetMore operation and return a Response. :param operation: a _Query or _GetMore object. - :param unpack_res: A callable that decodes the wire protocol response. + :param run_with_conn: A callable ``(conn, operation, read_preference) -> Awaitable[Response]`` + that executes the operation on a given connection. :param address: Optional address when sending a message to a specific server, used for getMore. """ @@ -1932,30 +1932,18 @@ async def _run_operation( async with operation.conn_mgr._lock: async with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] err_handler.contribute_socket(operation.conn_mgr.conn) - return await server.run_operation( - operation.conn_mgr.conn, - operation, - operation.read_preference, - self._event_listeners, - unpack_res, - self, + return await run_with_conn( + operation.conn_mgr.conn, operation, operation.read_preference ) async def _cmd( _session: Optional[AsyncClientSession], - server: Server, + _server: Server, conn: AsyncConnection, read_preference: _ServerMode, ) -> Response: operation.reset() # Reset op in case of retry. - return await server.run_operation( - conn, - operation, - read_preference, - self._event_listeners, - unpack_res, - self, - ) + return await run_with_conn(conn, operation, read_preference) return await self._retryable_read( _cmd, @@ -2991,6 +2979,15 @@ async def _get_server(self) -> Server: operation_id=self._operation_id, ) + def _log_retry(self, is_write: bool) -> None: + log_command_retry( + self._client._topology_id, + self._operation, + self._operation_id, + self._attempt_number, + is_write, + ) + async def _write(self) -> T: """Wrapper method for write-type retryable client executions @@ -3014,13 +3011,7 @@ async def _write(self) -> T: self._check_last_error() self._retryable = False if self._retrying: - _debug_log( - _COMMAND_LOGGER, - message=f"Retrying write attempt number {self._attempt_number}", - clientId=self._client._topology_id, - commandName=self._operation, - operationId=self._operation_id, - ) + self._log_retry(is_write=True) return await self._func(self._session, conn, self._retryable) # type: ignore except PyMongoError as exc: if not self._retryable: @@ -3043,13 +3034,7 @@ async def _read(self) -> T: if self._retrying and not self._retryable and not self._always_retryable: self._check_last_error() if self._retrying: - _debug_log( - _COMMAND_LOGGER, - message=f"Retrying read attempt number {self._attempt_number}", - clientId=self._client._topology_settings._topology_id, - commandName=self._operation, - operationId=self._operation_id, - ) + self._log_retry(is_write=False) return await self._func(self._session, self._server, conn, read_pref) # type: ignore diff --git a/pymongo/asynchronous/monitor.py b/pymongo/asynchronous/monitor.py index 45c12b219f..537d9ea7f5 100644 --- a/pymongo/asynchronous/monitor.py +++ b/pymongo/asynchronous/monitor.py @@ -18,18 +18,17 @@ import asyncio import atexit -import logging import time import weakref from typing import TYPE_CHECKING, Any, Optional from pymongo import common, periodic_executor from pymongo._csot import MovingMinimum +from pymongo._telemetry import _HeartbeatTelemetry, _monotonic_duration, log_srv_monitor_failure from pymongo.asynchronous.srv_resolver import _SrvResolver from pymongo.errors import NetworkTimeout, _OperationCancelled from pymongo.hello import Hello from pymongo.lock import _async_create_lock -from pymongo.logger import _SDAM_LOGGER, _debug_log, _SDAMStatusMessage from pymongo.periodic_executor import _shutdown_executors from pymongo.pool_options import _is_faas from pymongo.read_preferences import MovingAverage @@ -54,15 +53,6 @@ def _sanitize(error: Exception) -> None: error.__cause__ = None -def _monotonic_duration(start: float) -> float: - """Return the duration since the given start time. - - Accounts for buggy platforms where time.monotonic() is not monotonic. - See PYTHON-4600. - """ - return max(0.0, time.monotonic() - start) - - class MonitorBase: def __init__(self, topology: Topology, name: str, interval: int, min_interval: float): """Base class to do periodic work on a background thread. @@ -151,9 +141,10 @@ def __init__( self._pool = pool self._settings = topology_settings self._listeners = self._settings._pool_options._event_listeners - self._publish = self._listeners is not None and self._listeners.enabled_for_server_heartbeat self._cancel_context: Optional[_CancellationContext] = None self._conn_id: Optional[int] = None + self._current_hb: Optional[_HeartbeatTelemetry] = None + self._awaited: bool = False self._rtt_monitor = _RttMonitor( topology, topology_settings, @@ -257,32 +248,17 @@ async def _check_server(self) -> ServerDescription: Returns a ServerDescription. """ self._conn_id = None - start = time.monotonic() + self._current_hb = None + self._awaited = False try: return await self._check_once() except ReferenceError: raise except Exception as error: _sanitize(error) - sd = self._server_description - address = sd.address - duration = _monotonic_duration(start) - awaited = bool(self._stream and sd.is_server_type_known and sd.topology_version) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_failed(address, duration, error, awaited) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_FAIL, - topologyId=self._topology._topology_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - durationMS=duration * 1000, - failure=error, - driverConnectionId=self._conn_id, - ) + address = self._server_description.address + if self._current_hb is not None: + self._current_hb.failed(error, self._conn_id, self._awaited) await self._reset_connection() if isinstance(error, _OperationCancelled): raise @@ -300,28 +276,17 @@ async def _check_once(self) -> ServerDescription: # XXX: "awaited" could be incorrectly set to True in the rare case # the pool checkout closes and recreates a connection. - awaited = bool( + self._awaited = bool( self._pool.conns and self._stream and sd.is_server_type_known and sd.topology_version ) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_started(address, awaited) + hb = _HeartbeatTelemetry(self._topology._topology_id, address, self._listeners) + self._current_hb = hb + hb.started(self._awaited) if self._cancel_context and self._cancel_context.cancelled: await self._reset_connection() async with self._pool.checkout() as conn: - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_START, - topologyId=self._topology._topology_id, - driverConnectionId=conn.id, - serverConnectionId=conn.server_connection_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - ) - + hb.emit_started_log(conn.id, conn.server_connection_id, self._awaited) self._cancel_context = conn.cancel_context # Record the connection id so we can later attach it to the failed log message. self._conn_id = conn.id @@ -331,24 +296,7 @@ async def _check_once(self) -> ServerDescription: avg_rtt, min_rtt = await self._rtt_monitor.get() sd = ServerDescription(address, response, avg_rtt, min_round_trip_time=min_rtt) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_succeeded( - address, round_trip_time, response, response.awaitable - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_SUCCESS, - topologyId=self._topology._topology_id, - driverConnectionId=conn.id, - serverConnectionId=conn.server_connection_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - durationMS=round_trip_time * 1000, - reply=response.document, - ) + hb.succeeded(round_trip_time, response, conn.id, conn.server_connection_id) return sd async def _check_with_socket(self, conn: AsyncConnection) -> tuple[Hello, float]: # type: ignore[type-arg] @@ -429,7 +377,7 @@ async def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]: # - SRV records must be rescanned every heartbeatFrequencyMS # - Topology must be left unchanged self.request_check() - _debug_log(_SDAM_LOGGER, message="SRV monitor check failed", failure=repr(exc)) + log_srv_monitor_failure(exc) return None else: self._executor.update_interval(max(ttl, common.MIN_SRV_RESCAN_INTERVAL)) diff --git a/pymongo/asynchronous/pool.py b/pymongo/asynchronous/pool.py index 4ed3b85dbf..b3cfae8a45 100644 --- a/pymongo/asynchronous/pool.py +++ b/pymongo/asynchronous/pool.py @@ -17,7 +17,6 @@ import asyncio import collections import contextlib -import logging import os import socket import ssl @@ -35,6 +34,7 @@ from bson import DEFAULT_CODEC_OPTIONS from pymongo import _csot, helpers_shared +from pymongo._telemetry import _CmapTelemetry from pymongo.asynchronous.client_session import _validate_session_write_concern from pymongo.asynchronous.command_runner import run_command from pymongo.asynchronous.helpers import _handle_reauth @@ -65,12 +65,6 @@ _async_create_condition, _async_create_lock, ) -from pymongo.logger import ( - _CONNECTION_LOGGER, - _ConnectionStatusMessage, - _debug_log, - _verbose_connection_error_reason, -) from pymongo.monitoring import ( ConnectionCheckOutFailedReason, ConnectionClosedReason, @@ -134,6 +128,7 @@ def __init__( self.id = id self.is_sdam = is_sdam self.closed = False + self.creation_time = time.monotonic() self.last_checkin_time = time.monotonic() self.performed_handshake = False self.is_writable: bool = False @@ -145,8 +140,7 @@ def __init__( self.hello_ok: bool = False self.is_mongos = False self.listeners = pool.opts._event_listeners - self.enabled_for_cmap = pool.enabled_for_cmap - self.enabled_for_logging = pool.enabled_for_logging + self._telemetry = pool._telemetry self.compression_settings = pool.opts._compression_settings self.compression_context: Union[SnappyContext, ZlibContext, ZstdContext, None] = None self.socket_checker: SocketChecker = SocketChecker() @@ -173,8 +167,6 @@ def __init__( self.active = False self.last_timeout = self.opts.socket_timeout self.connect_rtt = 0.0 - self._client_id = pool._client_id - self.creation_time = time.monotonic() # For gossiping $clusterTime from the connection handshake to the client. self._cluster_time = None @@ -475,21 +467,7 @@ async def authenticate(self, reauthenticate: bool = False) -> None: await auth.authenticate(creds, self, reauthenticate=reauthenticate) self.ready = True - duration = time.monotonic() - self.creation_time - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_READY, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=self.id, - durationMS=duration, - ) - if self.enabled_for_cmap: - assert self.listeners is not None - self.listeners.publish_connection_ready(self.address, self.id, duration) + self._telemetry.connection_ready(self.id, self.creation_time) def validate_session( self, client: Optional[AsyncMongoClient[Any]], session: Optional[AsyncClientSession] @@ -510,20 +488,7 @@ async def close_conn(self, reason: Optional[str]) -> None: return await self._close_conn() if reason: - if self.enabled_for_cmap: - assert self.listeners is not None - self.listeners.publish_connection_closed(self.address, self.id, reason) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=self.id, - reason=_verbose_connection_error_reason(reason), - error=reason, - ) + self._telemetry.connection_closed(self.id, reason) async def _close_conn(self) -> None: """Close this connection.""" @@ -699,13 +664,6 @@ def __init__( self.address = address self.opts = options self.is_sdam = is_sdam - # Don't publish events or logs in Monitor pools. - self.enabled_for_cmap = ( - not self.is_sdam - and self.opts._event_listeners is not None - and self.opts._event_listeners.enabled_for_cmap - ) - self.enabled_for_logging = not self.is_sdam # The first portion of the wait queue. # Enforces: maxPoolSize @@ -721,25 +679,13 @@ def __init__( self._max_connecting_cond = _async_create_condition(self.lock) self._pending = 0 self._max_connecting = self.opts.max_connecting - self._client_id = client_id self._ssl_session_cache: Optional[list[Any]] = ( [None] if self.opts._ssl_context is not None else None ) - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CREATED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - **self.opts.non_default_options, - ) - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_pool_created( - self.address, self.opts.non_default_options - ) + self._telemetry = _CmapTelemetry( + client_id, address, options._event_listeners, publish=not is_sdam, log=not is_sdam + ) + self._telemetry.pool_created(self.opts.non_default_options) # Similar to active_sockets but includes threads in the wait queue. self.operation_count: int = 0 # Retain references to pinned connections to prevent the CPython GC @@ -754,17 +700,7 @@ async def ready(self) -> None: async with self.lock: if self.state != PoolState.READY: self.state = PoolState.READY - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_READY, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_pool_ready(self.address) + self._telemetry.pool_ready() @property def closed(self) -> bool: @@ -812,23 +748,7 @@ async def _reset( # and free-threaded Python causes ConnectionCheckOutFailedEvent to # arrive before PoolClearedEvent (PYTHON-3519). if not close and old_state != PoolState.PAUSED: - _listeners = self.opts._event_listeners - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CLEARED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - serviceId=service_id, - ) - if self.enabled_for_cmap: - assert _listeners is not None - _listeners.publish_pool_cleared( - self.address, - service_id=service_id, - interrupt_connections=interrupt_connections, - ) + self._telemetry.pool_cleared(service_id, interrupt_connections) # Clear the wait queue self._max_connecting_cond.notify_all() @@ -838,7 +758,6 @@ async def _reset( for context in self.active_contexts: context.cancel() - listeners = self.opts._event_listeners # CMAP spec says that close() MUST close sockets before publishing the # PoolClosedEvent but that reset() SHOULD close sockets *after* # publishing the PoolClearedEvent. @@ -851,17 +770,7 @@ async def _reset( else: for conn in sockets: await conn.close_conn(ConnectionClosedReason.POOL_CLOSED) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_pool_closed(self.address) + self._telemetry.pool_closed() else: if not _IS_SYNC: await asyncio.gather( @@ -996,20 +905,7 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A tmp_context = _CancellationContext() self.active_contexts.add(tmp_context) - listeners = self.opts._event_listeners - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CREATED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn_id, - ) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_created(self.address, conn_id) + self._telemetry.connection_created(conn_id) try: networking_interface = await _configured_protocol_interface( @@ -1019,22 +915,7 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A except BaseException as error: async with self.lock: self.active_contexts.discard(tmp_context) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_closed( - self.address, conn_id, ConnectionClosedReason.ERROR - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn_id, - reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), - error=ConnectionClosedReason.ERROR, - ) + self._telemetry.connection_closed(conn_id, ConnectionClosedReason.ERROR) if isinstance(error, (IOError, OSError, *SSLErrors)): details = _get_timeout_details(self.opts) # Wrap to AutoReconnect/NetworkTimeout BEFORE labeling so the @@ -1096,36 +977,11 @@ async def checkout( :param handler: A _MongoClientErrorHandler. """ - listeners = self.opts._event_listeners - checkout_started_time = time.monotonic() - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_check_out_started(self.address) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_STARTED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) + checkout_started_time = self._telemetry.checkout_started() conn = await self._get_conn(checkout_started_time, handler=handler) - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_checked_out(self.address, conn.id, duration) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_SUCCEEDED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - durationMS=duration, - ) + self._telemetry.checkout_succeeded(conn.id, checkout_started_time) try: async with self.lock: self.active_contexts.add(conn.cancel_context) @@ -1159,23 +1015,11 @@ async def checkout( def _raise_if_not_ready(self, checkout_started_time: float, emit_event: bool) -> None: if self.state != PoolState.READY: if emit_event: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.CONN_ERROR, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="An error occurred while trying to establish a new connection", - error=ConnectionCheckOutFailedReason.CONN_ERROR, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "An error occurred while trying to establish a new connection", + ConnectionCheckOutFailedReason.CONN_ERROR, + checkout_started_time, + ) details = _get_timeout_details(self.opts) _raise_connection_failure( @@ -1193,23 +1037,11 @@ async def _get_conn( await self.reset_without_pause() if self.closed: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.POOL_CLOSED, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="Connection pool was closed", - error=ConnectionCheckOutFailedReason.POOL_CLOSED, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "Connection pool was closed", + ConnectionCheckOutFailedReason.POOL_CLOSED, + checkout_started_time, + ) raise _PoolClosedError( "Attempted to check out a connection from closed connection pool" ) @@ -1289,23 +1121,11 @@ async def _get_conn( self.size_cond.notify() if not emitted_event: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.CONN_ERROR, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="An error occurred while trying to establish a new connection", - error=ConnectionCheckOutFailedReason.CONN_ERROR, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "An error occurred while trying to establish a new connection", + ConnectionCheckOutFailedReason.CONN_ERROR, + checkout_started_time, + ) raise conn.active = True @@ -1322,21 +1142,9 @@ async def checkin(self, conn: AsyncConnection) -> None: conn.pinned_txn = False conn.pinned_cursor = False self.__pinned_sockets.discard(conn) - listeners = self.opts._event_listeners async with self.lock: self.active_contexts.discard(conn.cancel_context) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_checked_in(self.address, conn.id) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKEDIN, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - ) + self._telemetry.checked_in(conn.id) if self.pid != os.getpid(): await self.reset_without_pause() else: @@ -1344,22 +1152,7 @@ async def checkin(self, conn: AsyncConnection) -> None: await conn.close_conn(ConnectionClosedReason.POOL_CLOSED) elif conn.closed: # CMAP requires the closed event be emitted after the check in. - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_closed( - self.address, conn.id, ConnectionClosedReason.ERROR - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), - error=ConnectionClosedReason.ERROR, - ) + self._telemetry.connection_closed(conn.id, ConnectionClosedReason.ERROR) else: close_conn = False async with self.lock: @@ -1424,24 +1217,11 @@ async def _perished(self, conn: AsyncConnection) -> bool: return False def _raise_wait_queue_timeout(self, checkout_started_time: float) -> NoReturn: - listeners = self.opts._event_listeners - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.TIMEOUT, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="Wait queue timeout elapsed without a connection becoming available", - error=ConnectionCheckOutFailedReason.TIMEOUT, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "Wait queue timeout elapsed without a connection becoming available", + ConnectionCheckOutFailedReason.TIMEOUT, + checkout_started_time, + ) timeout = _csot.get_timeout() or self.opts.wait_queue_timeout if self.opts.load_balanced: other_ops = self.active_sockets - self.ncursors - self.ntxns diff --git a/pymongo/asynchronous/server.py b/pymongo/asynchronous/server.py index 57158dfc44..607cb3c492 100644 --- a/pymongo/asynchronous/server.py +++ b/pymongo/asynchronous/server.py @@ -16,43 +16,28 @@ from __future__ import annotations -import logging from contextlib import AbstractAsyncContextManager from typing import ( TYPE_CHECKING, Any, - Callable, Optional, - Union, ) -from pymongo.asynchronous.command_runner import run_cursor_command -from pymongo.asynchronous.helpers import _handle_reauth -from pymongo.logger import ( - _SDAM_LOGGER, - _debug_log, - _SDAMStatusMessage, -) -from pymongo.message import _GetMore, _OpMsg, _Query -from pymongo.response import PinnedResponse, Response +from pymongo._telemetry import _SdamTelemetry if TYPE_CHECKING: from queue import Queue from weakref import ReferenceType from bson.objectid import ObjectId - from pymongo.asynchronous.mongo_client import AsyncMongoClient, _MongoClientErrorHandler + from pymongo.asynchronous.mongo_client import _MongoClientErrorHandler from pymongo.asynchronous.monitor import Monitor from pymongo.asynchronous.pool import AsyncConnection, Pool from pymongo.monitoring import _EventListeners - from pymongo.read_preferences import _ServerMode from pymongo.server_description import ServerDescription - from pymongo.typings import _DocumentOut _IS_SYNC = False -_CURSOR_DOC_FIELDS = {"cursor": {"firstBatch": 1, "nextBatch": 1}} - class Server: def __init__( @@ -68,12 +53,8 @@ def __init__( self._description = server_description self._pool = pool self._monitor = monitor - self._topology_id = topology_id - self._publish = listeners is not None and listeners.enabled_for_server - self._listener = listeners - self._events = None - if self._publish: - self._events = events() # type: ignore[misc] + _events = events() if listeners is not None and listeners.enabled_for_server else None # type: ignore[misc] + self._sdam = _SdamTelemetry(topology_id, listeners, _events) # type: ignore[arg-type] async def open(self) -> None: """Start monitoring, or restart after a fork. @@ -92,23 +73,7 @@ async def close(self) -> None: Reconnect with open(). """ - if self._publish: - assert self._listener is not None - assert self._events is not None - self._events.put( - ( - self._listener.publish_server_closed, - (self._description.address, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.STOP_SERVER, - topologyId=self._topology_id, - serverHost=self._description.address[0], - serverPort=self._description.address[1], - ) + self._sdam.server_closed(self._description.address) await self._monitor.close() await self._pool.close() @@ -117,112 +82,6 @@ def request_check(self) -> None: """Check the server's state soon.""" self._monitor.request_check() - async def operation_to_command( - self, operation: Union[_Query, _GetMore], conn: AsyncConnection, apply_timeout: bool = False - ) -> tuple[dict[str, Any], str]: - cmd, db = operation.as_command(conn, apply_timeout) - # Support auto encryption - if operation.client._encrypter and not operation.client._encrypter._bypass_auto_encryption: - cmd = await operation.client._encrypter.encrypt( # type: ignore[misc, assignment] - operation.db, cmd, operation.codec_options - ) - operation.update_command(cmd) - - return cmd, db - - @_handle_reauth - async def run_operation( - self, - conn: AsyncConnection, - operation: Union[_Query, _GetMore], - read_preference: _ServerMode, - listeners: Optional[_EventListeners], - unpack_res: Callable[..., list[_DocumentOut]], - client: AsyncMongoClient[Any], - ) -> Response: - """Run a _Query or _GetMore operation and return a Response object. - - This method is used only to run _Query/_GetMore operations from - cursors. - Can raise ConnectionFailure, OperationFailure, etc. - - :param conn: An AsyncConnection instance. - :param operation: A _Query or _GetMore object. - :param read_preference: The read preference to use. - :param listeners: Instance of _EventListeners or None. - :param unpack_res: A callable that decodes the wire protocol response. - :param client: An AsyncMongoClient instance. - """ - assert listeners is not None - - use_cmd = operation.use_command(conn) - more_to_come = bool(operation.conn_mgr and operation.conn_mgr.more_to_come) - cmd, dbn = await self.operation_to_command(operation, conn, use_cmd) - if more_to_come: - request_id = 0 - data = b"" - max_doc_size = 0 - else: - message = operation.get_message(read_preference, conn, use_cmd) - request_id, data, max_doc_size = self._split_message(message) - - user_fields = _CURSOR_DOC_FIELDS if use_cmd else None - - docs, reply, duration = await run_cursor_command( - conn, - cmd, - dbn, - request_id, - data, - client=client, - session=operation.session, # type: ignore[arg-type] - listeners=listeners, - codec_options=operation.codec_options, - user_fields=user_fields, - command_name=operation.name, - pool_opts=conn.opts, - max_doc_size=max_doc_size, - more_to_come=more_to_come, - unpack_res=unpack_res, - cursor_id=operation.cursor_id, - ) - assert reply is not None - - response: Response - - if client._should_pin_cursor(operation.session) or operation.exhaust: # type: ignore[arg-type] - conn.pin_cursor() - if isinstance(reply, _OpMsg): - # In OP_MSG, the server keeps sending only if the - # more_to_come flag is set. - more_to_come = reply.more_to_come - else: - # In OP_REPLY, the server keeps sending until cursor_id is 0. - more_to_come = bool(operation.exhaust and reply.cursor_id) - if operation.conn_mgr: - operation.conn_mgr.update_exhaust(more_to_come) - response = PinnedResponse( - data=reply, - address=self._description.address, - conn=conn, - duration=duration, - request_id=request_id, - from_command=use_cmd, - docs=docs, # type: ignore[arg-type] - more_to_come=more_to_come, - ) - else: - response = Response( - data=reply, - address=self._description.address, - duration=duration, - request_id=request_id, - from_command=use_cmd, - docs=docs, # type: ignore[arg-type] - ) - - return response - async def checkout( self, handler: Optional[_MongoClientErrorHandler] = None ) -> AbstractAsyncContextManager[AsyncConnection]: @@ -241,19 +100,5 @@ def description(self, server_description: ServerDescription) -> None: def pool(self) -> Pool: return self._pool - def _split_message( - self, message: Union[tuple[int, Any], tuple[int, Any, int]] - ) -> tuple[int, Any, int]: - """Return request_id, data, max_doc_size. - - :param message: (request_id, data, max_doc_size) or (request_id, data) - """ - if len(message) == 3: - return message # type: ignore[return-value] - else: - # get_more and kill_cursors messages don't include BSON documents. - request_id, data = message # type: ignore[misc] - return request_id, data, 0 - def __repr__(self) -> str: return f"<{self.__class__.__name__} {self._description!r}>" diff --git a/pymongo/asynchronous/topology.py b/pymongo/asynchronous/topology.py index 6af075cdc2..ca8244c02a 100644 --- a/pymongo/asynchronous/topology.py +++ b/pymongo/asynchronous/topology.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -import logging import os import queue import random @@ -30,6 +29,11 @@ from typing import TYPE_CHECKING, Any, Callable, Optional, cast from pymongo import _csot, common, helpers_shared, periodic_executor +from pymongo._telemetry import ( + _SdamTelemetry, + _ServerSelectionTelemetry, + log_server_selection_succeeded, +) from pymongo.asynchronous.client_session import _ServerSession, _ServerSessionPool from pymongo.asynchronous.monitor import MonitorBase, SrvMonitor from pymongo.asynchronous.pool import Pool @@ -51,13 +55,6 @@ _async_create_condition, _async_create_lock, ) -from pymongo.logger import ( - _SDAM_LOGGER, - _SERVER_SELECTION_LOGGER, - _debug_log, - _SDAMStatusMessage, - _ServerSelectionStatusMessage, -) from pymongo.pool_options import PoolOptions from pymongo.server_description import ServerDescription from pymongo.server_selectors import ( @@ -108,27 +105,19 @@ class Topology: def __init__(self, topology_settings: TopologySettings): self._topology_id = topology_settings._topology_id self._listeners = topology_settings._pool_options._event_listeners - self._publish_server = self._listeners is not None and self._listeners.enabled_for_server - self._publish_tp = self._listeners is not None and self._listeners.enabled_for_topology # Create events queue if there are publishers. self._events: queue.Queue[Any] | None = None self.__events_executor: Any = None - if self._publish_server or self._publish_tp: + publish_server = self._listeners is not None and self._listeners.enabled_for_server + publish_tp = self._listeners is not None and self._listeners.enabled_for_topology + if publish_server or publish_tp: self._events = queue.Queue(maxsize=100) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.START_TOPOLOGY, - topologyId=self._topology_id, - ) + self._sdam = _SdamTelemetry(self._topology_id, self._listeners, self._events) + self._sdam.topology_opened() - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put((self._listeners.publish_topology_opened, (self._topology_id,))) self._settings = topology_settings topology_description = TopologyDescription( topology_settings.get_topology_type(), @@ -143,37 +132,10 @@ def __init__(self, topology_settings: TopologySettings): initial_td = TopologyDescription( TOPOLOGY_TYPE.Unknown, {}, None, None, None, self._settings ) - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (initial_td, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(initial_td), - newDescription=repr(self._description), - ) + self._sdam.topology_description_changed(initial_td, self._description) for seed in topology_settings.seeds: - if self._publish_server: - assert self._events is not None - assert self._listeners is not None - self._events.put((self._listeners.publish_server_opened, (seed, self._topology_id))) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.START_SERVER, - topologyId=self._topology_id, - serverHost=seed[0], - serverPort=seed[1], - ) + self._sdam.server_opened(seed) # Store the seed list to help diagnose errors in _error_message(). self._seed_addresses = list(topology_description.server_descriptions()) @@ -188,7 +150,7 @@ def __init__(self, topology_settings: TopologySettings): self._max_cluster_time: Optional[ClusterTime] = None self._session_pool = _ServerSessionPool() - if self._publish_server or self._publish_tp: + if self._sdam._publish_server or self._sdam._publish_tp: assert self._events is not None weak: weakref.ReferenceType[queue.Queue[Any]] @@ -321,17 +283,10 @@ async def _select_servers_loop( now = time.monotonic() end_time = now + timeout logged_waiting = False - - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.STARTED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - ) + ss = _ServerSelectionTelemetry( + self._topology_id, selector, operation, operation_id, self.description + ) + ss.started() server_descriptions = self._description.apply_selector( selector, @@ -345,32 +300,13 @@ async def _select_servers_loop( while not server_descriptions: # No suitable servers. if timeout == 0 or now > end_time: - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.FAILED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - failure=self._error_message(selector), - ) + ss.failed(self._error_message(selector), self.description) raise ServerSelectionTimeoutError( f"{self._error_message(selector)}, Timeout: {timeout}s, Topology Description: {self.description!r}" ) if not logged_waiting: - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.WAITING, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - remainingTimeMS=int(1000 * (end_time - time.monotonic())), - ) + ss.waiting(int(1000 * (end_time - time.monotonic()))) logged_waiting = True await self._ensure_opened() @@ -435,18 +371,15 @@ async def select_server( ) if _csot.get_timeout(): _csot.set_rtt(server.description.min_round_trip_time) - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.SUCCEEDED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - serverHost=server.description.address[0], - serverPort=server.description.address[1], - ) + log_server_selection_succeeded( + self._topology_id, + selector, + operation, + operation_id, + self.description, + server.description.address[0], + server.description.address[1], + ) return server async def select_server_by_address( @@ -508,36 +441,16 @@ async def _process_change( await server.pool.ready() suppress_event = sd_old == server_description - if self._publish_server and not suppress_event: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_server_description_changed, - (sd_old, server_description, server_description.address, self._topology_id), - ) + if not suppress_event: + self._sdam.server_description_changed( + sd_old, server_description, server_description.address ) self._description = new_td await self._update_servers() - if self._publish_tp and not suppress_event: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (td_old, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG) and not suppress_event: - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(td_old), - newDescription=repr(self._description), - ) + if not suppress_event: + self._sdam.topology_description_changed(td_old, self._description) # Shutdown SRV polling for unsupported cluster types. # This is only applicable if the old topology was Unknown, and the @@ -588,24 +501,7 @@ async def _process_srv_update(self, seedlist: list[tuple[str, Any]]) -> None: self._description = _updated_topology_description_srv_polling(self._description, seedlist) await self._update_servers() - - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (td_old, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(td_old), - newDescription=repr(self._description), - ) + self._sdam.topology_description_changed(td_old, self._description) async def on_srv_update(self, seedlist: list[tuple[str, Any]]) -> None: """Process a new list of nodes obtained from scanning SRV records.""" @@ -743,9 +639,7 @@ async def close(self) -> None: self._closed = True # Publish only after releasing the lock. - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None + if self._sdam._publish_tp: self._description = TopologyDescription( TOPOLOGY_TYPE.Unknown, {}, @@ -754,30 +648,9 @@ async def close(self) -> None: self._description.max_election_id, self._description._topology_settings, ) - self._events.put( - ( - self._listeners.publish_topology_description_changed, - ( - old_td, - self._description, - self._topology_id, - ), - ) - ) - self._events.put((self._listeners.publish_topology_closed, (self._topology_id,))) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(old_td), - newDescription=repr(self._description), - ) - _debug_log( - _SDAM_LOGGER, message=_SDAMStatusMessage.STOP_TOPOLOGY, topologyId=self._topology_id - ) + self._sdam.topology_closed(old_td, self._description) - if self._publish_server or self._publish_tp: + if self._sdam._publish_server or self._sdam._publish_tp: # Make sure the events executor thread is fully closed before publishing the remaining events self.__events_executor.close() await self.__events_executor.join(1) @@ -818,7 +691,7 @@ async def _ensure_opened(self) -> None: await self._update_servers() # Start or restart the events publishing thread. - if self._publish_tp or self._publish_server: + if self._sdam._publish_tp or self._sdam._publish_server: self.__events_executor.open() # Start the SRV polling thread. @@ -957,7 +830,7 @@ async def _update_servers(self) -> None: ) weak = None - if self._publish_server and self._events is not None: + if self._sdam._publish_server and self._events is not None: weak = weakref.ref(self._events) server = Server( server_description=sd, diff --git a/pymongo/common.py b/pymongo/common.py index 08da34c7bf..7baed05d3e 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -482,14 +482,16 @@ def validate_document_class( option: str, value: Any ) -> Union[type[MutableMapping[str, Any]], type[RawBSONDocument]]: """Validate the document_class option.""" - # issubclass can raise TypeError for generic aliases like SON[str, Any]. - # In that case we can use the base class for the comparison. - is_mapping = False + # Generic aliases like SON[str, Any] or dict[str, Any] aren't classes, so + # resolve to their origin before the subclass check. Whether issubclass() + # raises TypeError or just returns False for such aliases is inconsistent + # across Python implementations (e.g. PyPy vs CPython), so check the + # origin proactively instead of relying on catching the error. + check_class = getattr(value, "__origin__", value) try: - is_mapping = issubclass(value, abc.MutableMapping) + is_mapping = issubclass(check_class, abc.MutableMapping) except TypeError: - if hasattr(value, "__origin__"): - is_mapping = issubclass(value.__origin__, abc.MutableMapping) + is_mapping = False if not is_mapping and not issubclass(value, RawBSONDocument): raise TypeError( f"{option} must be dict, bson.son.SON, " diff --git a/pymongo/cursor_shared.py b/pymongo/cursor_shared.py index 5d58f1845b..df0e1e2f58 100644 --- a/pymongo/cursor_shared.py +++ b/pymongo/cursor_shared.py @@ -24,6 +24,22 @@ from pymongo.message import _CursorAddress from pymongo.typings import _Address, _DocumentType +_CURSOR_DOC_FIELDS = {"cursor": {"firstBatch": 1, "nextBatch": 1}} + + +def _split_message( + message: Union[tuple[int, bytes], tuple[int, bytes, int]], +) -> tuple[int, bytes, int]: + """Return request_id, data, max_doc_size. + + :param message: (request_id, data, max_doc_size) or (request_id, data) + """ + if len(message) == 3: + return message + # get_more and kill_cursors messages don't include BSON documents. + request_id, data = message + return request_id, data, 0 + class _AgnosticCursorBase(Generic[_DocumentType], ABC): """ diff --git a/pymongo/ocsp_support.py b/pymongo/ocsp_support.py index ecdd8d96ae..bb8a4ae109 100644 --- a/pymongo/ocsp_support.py +++ b/pymongo/ocsp_support.py @@ -67,6 +67,7 @@ ec, ed448, ed25519, + mldsa, rsa, x448, x25519, @@ -87,6 +88,9 @@ ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey, ed448.Ed448PublicKey, + mldsa.MLDSA44PublicKey, + mldsa.MLDSA65PublicKey, + mldsa.MLDSA87PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, ] diff --git a/pymongo/synchronous/auth_oidc.py b/pymongo/synchronous/auth_oidc.py index 6b723b94d8..6ed8dd1827 100644 --- a/pymongo/synchronous/auth_oidc.py +++ b/pymongo/synchronous/auth_oidc.py @@ -51,9 +51,6 @@ def _get_authenticator( credentials: MongoCredential, address: tuple[str, int] ) -> _OIDCAuthenticator: - if credentials.cache.data: - return credentials.cache.data - # Extract values. principal_name = credentials.username properties = credentials.mechanism_properties @@ -72,6 +69,9 @@ def _get_authenticator( f"Refusing to connect to {address[0]}, which is not in authOIDCAllowedHosts: {allowed_hosts}" ) + if credentials.cache.data: + return credentials.cache.data + # Get or create the cache data. credentials.cache.data = _OIDCAuthenticator(username=principal_name, properties=properties) return credentials.cache.data diff --git a/pymongo/synchronous/command_cursor.py b/pymongo/synchronous/command_cursor.py index 82a9732bd9..8868d87939 100644 --- a/pymongo/synchronous/command_cursor.py +++ b/pymongo/synchronous/command_cursor.py @@ -42,7 +42,18 @@ class CommandCursor(_CursorBase[_DocumentType]): - """A cursor / iterator over command cursors.""" + """A cursor / iterator over command cursors. + Used by :meth:`~pymongo.collection.Collection.aggregate`, + :meth:`~pymongo.database.Database.aggregate`, + :meth:`~pymongo.collection.Collection.list_indexes`, + :meth:`~pymongo.collection.Collection.list_search_indexes` + :meth:`~pymongo.database.Database.list_collections`, + :meth:`~pymongo.database.Database.cursor_command`, + and :meth:`~pymongo.mongo_client.MongoClient.list_databases` + to iterate MongoDB command results. + + Should not be called directly by application developers. + """ _getmore_class = _GetMore @@ -161,9 +172,7 @@ def _send_message(self, operation: _GetMore) -> None: """Send a getmore message and handle the response.""" client = self._collection.database.client try: - response = client._run_operation( - operation, self._unpack_response, address=self._address - ) + response = client._run_operation(operation, self._run_with_conn, address=self._address) except OperationFailure as exc: if exc.code in _CURSOR_CLOSED_ERRORS: # Don't send killCursors because the cursor is already closed. diff --git a/pymongo/synchronous/command_runner.py b/pymongo/synchronous/command_runner.py index 077e0f9409..eec58722bb 100644 --- a/pymongo/synchronous/command_runner.py +++ b/pymongo/synchronous/command_runner.py @@ -24,7 +24,7 @@ batches. Pre-encrypted, so decryption is skipped. Callers: ``bulk.py``, ``client_bulk.py``. - :func:`run_cursor_command` — cursor ``find``/``getMore`` operations with - exhaust-cursor handling. Caller: ``server.py``. + exhaust-cursor handling. Caller: ``cursor_base.py``. :func:`_run_command` owns the entire shared skeleton: command logging, APM event publishing, ``send``/``receive``, ``$clusterTime`` gossip, diff --git a/pymongo/synchronous/cursor.py b/pymongo/synchronous/cursor.py index e1fdf2af0e..7ea81ea9ac 100644 --- a/pymongo/synchronous/cursor.py +++ b/pymongo/synchronous/cursor.py @@ -97,9 +97,9 @@ def __init__( let: Optional[bool] = None, ) -> None: """Create a new cursor. + Used by :meth:`~pymongo.collection.Collection.find` to iterate over MongoDB query results. - Should not be called directly by application developers - see - :meth:`~pymongo.collection.Collection.find` instead. + Should not be called directly by application developers. .. seealso:: The MongoDB documentation on `cursors `_. """ @@ -977,9 +977,7 @@ def _send_message(self, operation: Union[_Query, _GetMore]) -> None: raise InvalidOperation("exhaust cursors do not support auto encryption") try: - response = client._run_operation( - operation, self._unpack_response, address=self._address - ) + response = client._run_operation(operation, self._run_with_conn, address=self._address) except OperationFailure as exc: if exc.code in _CURSOR_CLOSED_ERRORS or self._exhaust: # Don't send killCursors because the cursor is already closed. diff --git a/pymongo/synchronous/cursor_base.py b/pymongo/synchronous/cursor_base.py index 96e69cb6ee..4cad4e0c09 100644 --- a/pymongo/synchronous/cursor_base.py +++ b/pymongo/synchronous/cursor_base.py @@ -17,20 +17,40 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Any, Optional +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Optional, Union from pymongo import _csot -from pymongo.cursor_shared import _AgnosticCursorBase +from pymongo.cursor_shared import _CURSOR_DOC_FIELDS, _AgnosticCursorBase, _split_message from pymongo.lock import _create_lock -from pymongo.typings import _DocumentType +from pymongo.message import _GetMore, _OpMsg, _Query +from pymongo.response import PinnedResponse, Response +from pymongo.synchronous.command_runner import run_cursor_command +from pymongo.synchronous.helpers import _handle_reauth +from pymongo.typings import _DocumentOut, _DocumentType if TYPE_CHECKING: + from pymongo.read_preferences import _ServerMode from pymongo.synchronous.client_session import ClientSession from pymongo.synchronous.pool import Connection _IS_SYNC = True +def _operation_to_command( + operation: Union[_Query, _GetMore], + conn: Connection, + apply_timeout: bool, +) -> tuple[dict[str, Any], str]: + cmd, db = operation.as_command(conn, apply_timeout) + if operation.client._encrypter and not operation.client._encrypter._bypass_auto_encryption: + cmd = operation.client._encrypter.encrypt( # type: ignore[misc, assignment] + operation.db, cmd, operation.codec_options + ) + operation.update_command(cmd) + return cmd, db + + class _ConnectionManager: """Used with exhaust cursors to ensure the connection is returned.""" @@ -66,6 +86,87 @@ def session(self) -> Optional[ClientSession]: def _next_batch(self, result: list, total: Optional[int] = None) -> bool: # type: ignore[type-arg] ... + @abstractmethod + def _unpack_response( + self, + response: _OpMsg, + cursor_id: Optional[int], + codec_options: Any, + user_fields: Optional[Mapping[str, Any]] = None, + legacy_response: bool = False, + ) -> Sequence[_DocumentOut]: ... + + @_handle_reauth + def _run_with_conn( + self, + conn: Connection, + operation: Union[_Query, _GetMore], + read_preference: _ServerMode, + ) -> Response: + """Execute a cursor operation on the given connection and return a Response. + + :param conn: A Connection instance. + :param operation: A _Query or _GetMore object. + :param read_preference: The read preference to use. + """ + client = self._collection.database.client + use_cmd = operation.use_command(conn) + more_to_come = bool(operation.conn_mgr and operation.conn_mgr.more_to_come) + cmd, dbn = _operation_to_command(operation, conn, use_cmd) + if more_to_come: + request_id, data, max_doc_size = 0, b"", 0 + else: + message = operation.get_message(read_preference, conn, use_cmd) + request_id, data, max_doc_size = _split_message(message) + user_fields = _CURSOR_DOC_FIELDS if use_cmd else None + docs, reply, duration = run_cursor_command( + conn, + cmd, + dbn, + request_id, + data, + client=client, + session=operation.session, # type: ignore[arg-type] + listeners=client._event_listeners, + codec_options=operation.codec_options, + user_fields=user_fields, + command_name=operation.name, + pool_opts=conn.opts, + max_doc_size=max_doc_size, + more_to_come=more_to_come, + unpack_res=self._unpack_response, + cursor_id=operation.cursor_id, + ) + assert reply is not None + if client._should_pin_cursor(operation.session) or operation.exhaust: # type: ignore[arg-type] + conn.pin_cursor() + if isinstance(reply, _OpMsg): + # In OP_MSG, the server keeps sending only if the more_to_come flag is set. + more_to_come = reply.more_to_come + else: + # In OP_REPLY, the server keeps sending until cursor_id is 0. + more_to_come = bool(operation.exhaust and reply.cursor_id) + if operation.conn_mgr: + operation.conn_mgr.update_exhaust(more_to_come) + return PinnedResponse( + data=reply, + address=conn.address, + conn=conn, + duration=duration, + request_id=request_id, + from_command=use_cmd, + docs=docs, # type: ignore[arg-type] + more_to_come=more_to_come, + ) + return Response( + data=reply, + address=conn.address, + duration=duration, + request_id=request_id, + from_command=use_cmd, + docs=docs, # type: ignore[arg-type] + ) + def _die_lock(self) -> None: """Closes this cursor.""" try: diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 5f321afe5c..24a0401410 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -57,6 +57,7 @@ from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, TypeRegistry from bson.timestamp import Timestamp from pymongo import _csot, common, helpers_shared, periodic_executor +from pymongo._telemetry import log_command_retry from pymongo.client_options import ClientOptions from pymongo.driver_info import DriverInfo from pymongo.errors import ( @@ -80,8 +81,6 @@ ) from pymongo.logger import ( _CLIENT_LOGGER, - _COMMAND_LOGGER, - _debug_log, _log_client_error, _log_or_warn, ) @@ -1908,13 +1907,14 @@ def _conn_for_reads( def _run_operation( self, operation: Union[_Query, _GetMore], - unpack_res: Callable, # type: ignore[type-arg] + run_with_conn: Callable, # type: ignore[type-arg] address: Optional[_Address] = None, ) -> Response: """Run a _Query/_GetMore operation and return a Response. :param operation: a _Query or _GetMore object. - :param unpack_res: A callable that decodes the wire protocol response. + :param run_with_conn: A callable ``(conn, operation, read_preference) -> Response`` + that executes the operation on a given connection. :param address: Optional address when sending a message to a specific server, used for getMore. """ @@ -1929,30 +1929,18 @@ def _run_operation( with operation.conn_mgr._lock: with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] err_handler.contribute_socket(operation.conn_mgr.conn) - return server.run_operation( - operation.conn_mgr.conn, - operation, - operation.read_preference, - self._event_listeners, - unpack_res, - self, + return run_with_conn( + operation.conn_mgr.conn, operation, operation.read_preference ) def _cmd( _session: Optional[ClientSession], - server: Server, + _server: Server, conn: Connection, read_preference: _ServerMode, ) -> Response: operation.reset() # Reset op in case of retry. - return server.run_operation( - conn, - operation, - read_preference, - self._event_listeners, - unpack_res, - self, - ) + return run_with_conn(conn, operation, read_preference) return self._retryable_read( _cmd, @@ -2982,6 +2970,15 @@ def _get_server(self) -> Server: operation_id=self._operation_id, ) + def _log_retry(self, is_write: bool) -> None: + log_command_retry( + self._client._topology_id, + self._operation, + self._operation_id, + self._attempt_number, + is_write, + ) + def _write(self) -> T: """Wrapper method for write-type retryable client executions @@ -3005,13 +3002,7 @@ def _write(self) -> T: self._check_last_error() self._retryable = False if self._retrying: - _debug_log( - _COMMAND_LOGGER, - message=f"Retrying write attempt number {self._attempt_number}", - clientId=self._client._topology_id, - commandName=self._operation, - operationId=self._operation_id, - ) + self._log_retry(is_write=True) return self._func(self._session, conn, self._retryable) # type: ignore except PyMongoError as exc: if not self._retryable: @@ -3034,13 +3025,7 @@ def _read(self) -> T: if self._retrying and not self._retryable and not self._always_retryable: self._check_last_error() if self._retrying: - _debug_log( - _COMMAND_LOGGER, - message=f"Retrying read attempt number {self._attempt_number}", - clientId=self._client._topology_settings._topology_id, - commandName=self._operation, - operationId=self._operation_id, - ) + self._log_retry(is_write=False) return self._func(self._session, self._server, conn, read_pref) # type: ignore diff --git a/pymongo/synchronous/monitor.py b/pymongo/synchronous/monitor.py index f395588814..d56206b127 100644 --- a/pymongo/synchronous/monitor.py +++ b/pymongo/synchronous/monitor.py @@ -18,17 +18,16 @@ import asyncio import atexit -import logging import time import weakref from typing import TYPE_CHECKING, Any, Optional from pymongo import common, periodic_executor from pymongo._csot import MovingMinimum +from pymongo._telemetry import _HeartbeatTelemetry, _monotonic_duration, log_srv_monitor_failure from pymongo.errors import NetworkTimeout, _OperationCancelled from pymongo.hello import Hello from pymongo.lock import _create_lock -from pymongo.logger import _SDAM_LOGGER, _debug_log, _SDAMStatusMessage from pymongo.periodic_executor import _shutdown_executors from pymongo.pool_options import _is_faas from pymongo.read_preferences import MovingAverage @@ -54,15 +53,6 @@ def _sanitize(error: Exception) -> None: error.__cause__ = None -def _monotonic_duration(start: float) -> float: - """Return the duration since the given start time. - - Accounts for buggy platforms where time.monotonic() is not monotonic. - See PYTHON-4600. - """ - return max(0.0, time.monotonic() - start) - - class MonitorBase: def __init__(self, topology: Topology, name: str, interval: int, min_interval: float): """Base class to do periodic work on a background thread. @@ -151,9 +141,10 @@ def __init__( self._pool = pool self._settings = topology_settings self._listeners = self._settings._pool_options._event_listeners - self._publish = self._listeners is not None and self._listeners.enabled_for_server_heartbeat self._cancel_context: Optional[_CancellationContext] = None self._conn_id: Optional[int] = None + self._current_hb: Optional[_HeartbeatTelemetry] = None + self._awaited: bool = False self._rtt_monitor = _RttMonitor( topology, topology_settings, @@ -255,32 +246,17 @@ def _check_server(self) -> ServerDescription: Returns a ServerDescription. """ self._conn_id = None - start = time.monotonic() + self._current_hb = None + self._awaited = False try: return self._check_once() except ReferenceError: raise except Exception as error: _sanitize(error) - sd = self._server_description - address = sd.address - duration = _monotonic_duration(start) - awaited = bool(self._stream and sd.is_server_type_known and sd.topology_version) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_failed(address, duration, error, awaited) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_FAIL, - topologyId=self._topology._topology_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - durationMS=duration * 1000, - failure=error, - driverConnectionId=self._conn_id, - ) + address = self._server_description.address + if self._current_hb is not None: + self._current_hb.failed(error, self._conn_id, self._awaited) self._reset_connection() if isinstance(error, _OperationCancelled): raise @@ -298,28 +274,17 @@ def _check_once(self) -> ServerDescription: # XXX: "awaited" could be incorrectly set to True in the rare case # the pool checkout closes and recreates a connection. - awaited = bool( + self._awaited = bool( self._pool.conns and self._stream and sd.is_server_type_known and sd.topology_version ) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_started(address, awaited) + hb = _HeartbeatTelemetry(self._topology._topology_id, address, self._listeners) + self._current_hb = hb + hb.started(self._awaited) if self._cancel_context and self._cancel_context.cancelled: self._reset_connection() with self._pool.checkout() as conn: - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_START, - topologyId=self._topology._topology_id, - driverConnectionId=conn.id, - serverConnectionId=conn.server_connection_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - ) - + hb.emit_started_log(conn.id, conn.server_connection_id, self._awaited) self._cancel_context = conn.cancel_context # Record the connection id so we can later attach it to the failed log message. self._conn_id = conn.id @@ -329,24 +294,7 @@ def _check_once(self) -> ServerDescription: avg_rtt, min_rtt = self._rtt_monitor.get() sd = ServerDescription(address, response, avg_rtt, min_round_trip_time=min_rtt) - if self._publish: - assert self._listeners is not None - self._listeners.publish_server_heartbeat_succeeded( - address, round_trip_time, response, response.awaitable - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.HEARTBEAT_SUCCESS, - topologyId=self._topology._topology_id, - driverConnectionId=conn.id, - serverConnectionId=conn.server_connection_id, - serverHost=address[0], - serverPort=address[1], - awaited=awaited, - durationMS=round_trip_time * 1000, - reply=response.document, - ) + hb.succeeded(round_trip_time, response, conn.id, conn.server_connection_id) return sd def _check_with_socket(self, conn: Connection) -> tuple[Hello, float]: # type: ignore[type-arg] @@ -427,7 +375,7 @@ def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]: # - SRV records must be rescanned every heartbeatFrequencyMS # - Topology must be left unchanged self.request_check() - _debug_log(_SDAM_LOGGER, message="SRV monitor check failed", failure=repr(exc)) + log_srv_monitor_failure(exc) return None else: self._executor.update_interval(max(ttl, common.MIN_SRV_RESCAN_INTERVAL)) diff --git a/pymongo/synchronous/pool.py b/pymongo/synchronous/pool.py index 1006735444..9159ff704a 100644 --- a/pymongo/synchronous/pool.py +++ b/pymongo/synchronous/pool.py @@ -17,7 +17,6 @@ import asyncio import collections import contextlib -import logging import os import socket import ssl @@ -35,6 +34,7 @@ from bson import DEFAULT_CODEC_OPTIONS from pymongo import _csot, helpers_shared +from pymongo._telemetry import _CmapTelemetry from pymongo.common import ( MAX_BSON_SIZE, MAX_MESSAGE_SIZE, @@ -62,12 +62,6 @@ _create_condition, _create_lock, ) -from pymongo.logger import ( - _CONNECTION_LOGGER, - _ConnectionStatusMessage, - _debug_log, - _verbose_connection_error_reason, -) from pymongo.monitoring import ( ConnectionCheckOutFailedReason, ConnectionClosedReason, @@ -134,6 +128,7 @@ def __init__( self.id = id self.is_sdam = is_sdam self.closed = False + self.creation_time = time.monotonic() self.last_checkin_time = time.monotonic() self.performed_handshake = False self.is_writable: bool = False @@ -145,8 +140,7 @@ def __init__( self.hello_ok: bool = False self.is_mongos = False self.listeners = pool.opts._event_listeners - self.enabled_for_cmap = pool.enabled_for_cmap - self.enabled_for_logging = pool.enabled_for_logging + self._telemetry = pool._telemetry self.compression_settings = pool.opts._compression_settings self.compression_context: Union[SnappyContext, ZlibContext, ZstdContext, None] = None self.socket_checker: SocketChecker = SocketChecker() @@ -173,8 +167,6 @@ def __init__( self.active = False self.last_timeout = self.opts.socket_timeout self.connect_rtt = 0.0 - self._client_id = pool._client_id - self.creation_time = time.monotonic() # For gossiping $clusterTime from the connection handshake to the client. self._cluster_time = None @@ -475,21 +467,7 @@ def authenticate(self, reauthenticate: bool = False) -> None: auth.authenticate(creds, self, reauthenticate=reauthenticate) self.ready = True - duration = time.monotonic() - self.creation_time - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_READY, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=self.id, - durationMS=duration, - ) - if self.enabled_for_cmap: - assert self.listeners is not None - self.listeners.publish_connection_ready(self.address, self.id, duration) + self._telemetry.connection_ready(self.id, self.creation_time) def validate_session( self, client: Optional[MongoClient[Any]], session: Optional[ClientSession] @@ -508,20 +486,7 @@ def close_conn(self, reason: Optional[str]) -> None: return self._close_conn() if reason: - if self.enabled_for_cmap: - assert self.listeners is not None - self.listeners.publish_connection_closed(self.address, self.id, reason) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=self.id, - reason=_verbose_connection_error_reason(reason), - error=reason, - ) + self._telemetry.connection_closed(self.id, reason) def _close_conn(self) -> None: """Close this connection.""" @@ -697,13 +662,6 @@ def __init__( self.address = address self.opts = options self.is_sdam = is_sdam - # Don't publish events or logs in Monitor pools. - self.enabled_for_cmap = ( - not self.is_sdam - and self.opts._event_listeners is not None - and self.opts._event_listeners.enabled_for_cmap - ) - self.enabled_for_logging = not self.is_sdam # The first portion of the wait queue. # Enforces: maxPoolSize @@ -719,25 +677,13 @@ def __init__( self._max_connecting_cond = _create_condition(self.lock) self._pending = 0 self._max_connecting = self.opts.max_connecting - self._client_id = client_id self._ssl_session_cache: Optional[list[Any]] = ( [None] if self.opts._ssl_context is not None else None ) - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CREATED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - **self.opts.non_default_options, - ) - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_pool_created( - self.address, self.opts.non_default_options - ) + self._telemetry = _CmapTelemetry( + client_id, address, options._event_listeners, publish=not is_sdam, log=not is_sdam + ) + self._telemetry.pool_created(self.opts.non_default_options) # Similar to active_sockets but includes threads in the wait queue. self.operation_count: int = 0 # Retain references to pinned connections to prevent the CPython GC @@ -752,17 +698,7 @@ def ready(self) -> None: with self.lock: if self.state != PoolState.READY: self.state = PoolState.READY - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_READY, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_pool_ready(self.address) + self._telemetry.pool_ready() @property def closed(self) -> bool: @@ -810,23 +746,7 @@ def _reset( # and free-threaded Python causes ConnectionCheckOutFailedEvent to # arrive before PoolClearedEvent (PYTHON-3519). if not close and old_state != PoolState.PAUSED: - _listeners = self.opts._event_listeners - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CLEARED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - serviceId=service_id, - ) - if self.enabled_for_cmap: - assert _listeners is not None - _listeners.publish_pool_cleared( - self.address, - service_id=service_id, - interrupt_connections=interrupt_connections, - ) + self._telemetry.pool_cleared(service_id, interrupt_connections) # Clear the wait queue self._max_connecting_cond.notify_all() @@ -836,7 +756,6 @@ def _reset( for context in self.active_contexts: context.cancel() - listeners = self.opts._event_listeners # CMAP spec says that close() MUST close sockets before publishing the # PoolClosedEvent but that reset() SHOULD close sockets *after* # publishing the PoolClearedEvent. @@ -849,17 +768,7 @@ def _reset( else: for conn in sockets: conn.close_conn(ConnectionClosedReason.POOL_CLOSED) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.POOL_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_pool_closed(self.address) + self._telemetry.pool_closed() else: if not _IS_SYNC: asyncio.gather( @@ -992,20 +901,7 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect tmp_context = _CancellationContext() self.active_contexts.add(tmp_context) - listeners = self.opts._event_listeners - # Log before publishing event to prevent potential listener preemption in tests - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CREATED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn_id, - ) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_created(self.address, conn_id) + self._telemetry.connection_created(conn_id) try: networking_interface = _configured_socket_interface( @@ -1015,22 +911,7 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect except BaseException as error: with self.lock: self.active_contexts.discard(tmp_context) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_closed( - self.address, conn_id, ConnectionClosedReason.ERROR - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn_id, - reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), - error=ConnectionClosedReason.ERROR, - ) + self._telemetry.connection_closed(conn_id, ConnectionClosedReason.ERROR) if isinstance(error, (IOError, OSError, *SSLErrors)): details = _get_timeout_details(self.opts) # Wrap to AutoReconnect/NetworkTimeout BEFORE labeling so the @@ -1092,36 +973,11 @@ def checkout( :param handler: A _MongoClientErrorHandler. """ - listeners = self.opts._event_listeners - checkout_started_time = time.monotonic() - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_check_out_started(self.address) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_STARTED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - ) + checkout_started_time = self._telemetry.checkout_started() conn = self._get_conn(checkout_started_time, handler=handler) - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_checked_out(self.address, conn.id, duration) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_SUCCEEDED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - durationMS=duration, - ) + self._telemetry.checkout_succeeded(conn.id, checkout_started_time) try: with self.lock: self.active_contexts.add(conn.cancel_context) @@ -1155,23 +1011,11 @@ def checkout( def _raise_if_not_ready(self, checkout_started_time: float, emit_event: bool) -> None: if self.state != PoolState.READY: if emit_event: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.CONN_ERROR, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="An error occurred while trying to establish a new connection", - error=ConnectionCheckOutFailedReason.CONN_ERROR, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "An error occurred while trying to establish a new connection", + ConnectionCheckOutFailedReason.CONN_ERROR, + checkout_started_time, + ) details = _get_timeout_details(self.opts) _raise_connection_failure( @@ -1189,23 +1033,11 @@ def _get_conn( self.reset_without_pause() if self.closed: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.POOL_CLOSED, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="Connection pool was closed", - error=ConnectionCheckOutFailedReason.POOL_CLOSED, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "Connection pool was closed", + ConnectionCheckOutFailedReason.POOL_CLOSED, + checkout_started_time, + ) raise _PoolClosedError( "Attempted to check out a connection from closed connection pool" ) @@ -1285,23 +1117,11 @@ def _get_conn( self.size_cond.notify() if not emitted_event: - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert self.opts._event_listeners is not None - self.opts._event_listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.CONN_ERROR, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="An error occurred while trying to establish a new connection", - error=ConnectionCheckOutFailedReason.CONN_ERROR, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "An error occurred while trying to establish a new connection", + ConnectionCheckOutFailedReason.CONN_ERROR, + checkout_started_time, + ) raise conn.active = True @@ -1318,21 +1138,9 @@ def checkin(self, conn: Connection) -> None: conn.pinned_txn = False conn.pinned_cursor = False self.__pinned_sockets.discard(conn) - listeners = self.opts._event_listeners with self.lock: self.active_contexts.discard(conn.cancel_context) - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_checked_in(self.address, conn.id) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKEDIN, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - ) + self._telemetry.checked_in(conn.id) if self.pid != os.getpid(): self.reset_without_pause() else: @@ -1340,22 +1148,7 @@ def checkin(self, conn: Connection) -> None: conn.close_conn(ConnectionClosedReason.POOL_CLOSED) elif conn.closed: # CMAP requires the closed event be emitted after the check in. - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_closed( - self.address, conn.id, ConnectionClosedReason.ERROR - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CONN_CLOSED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - driverConnectionId=conn.id, - reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), - error=ConnectionClosedReason.ERROR, - ) + self._telemetry.connection_closed(conn.id, ConnectionClosedReason.ERROR) else: close_conn = False with self.lock: @@ -1420,24 +1213,11 @@ def _perished(self, conn: Connection) -> bool: return False def _raise_wait_queue_timeout(self, checkout_started_time: float) -> NoReturn: - listeners = self.opts._event_listeners - duration = time.monotonic() - checkout_started_time - if self.enabled_for_cmap: - assert listeners is not None - listeners.publish_connection_check_out_failed( - self.address, ConnectionCheckOutFailedReason.TIMEOUT, duration - ) - if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _CONNECTION_LOGGER, - message=_ConnectionStatusMessage.CHECKOUT_FAILED, - clientId=self._client_id, - serverHost=self.address[0], - serverPort=self.address[1], - reason="Wait queue timeout elapsed without a connection becoming available", - error=ConnectionCheckOutFailedReason.TIMEOUT, - durationMS=duration, - ) + self._telemetry.checkout_failed( + "Wait queue timeout elapsed without a connection becoming available", + ConnectionCheckOutFailedReason.TIMEOUT, + checkout_started_time, + ) timeout = _csot.get_timeout() or self.opts.wait_queue_timeout if self.opts.load_balanced: other_ops = self.active_sockets - self.ncursors - self.ntxns diff --git a/pymongo/synchronous/server.py b/pymongo/synchronous/server.py index 09d8fb75e1..ca5eee7b49 100644 --- a/pymongo/synchronous/server.py +++ b/pymongo/synchronous/server.py @@ -16,25 +16,14 @@ from __future__ import annotations -import logging from contextlib import AbstractContextManager from typing import ( TYPE_CHECKING, Any, - Callable, Optional, - Union, ) -from pymongo.logger import ( - _SDAM_LOGGER, - _debug_log, - _SDAMStatusMessage, -) -from pymongo.message import _GetMore, _OpMsg, _Query -from pymongo.response import PinnedResponse, Response -from pymongo.synchronous.command_runner import run_cursor_command -from pymongo.synchronous.helpers import _handle_reauth +from pymongo._telemetry import _SdamTelemetry if TYPE_CHECKING: from queue import Queue @@ -42,17 +31,13 @@ from bson.objectid import ObjectId from pymongo.monitoring import _EventListeners - from pymongo.read_preferences import _ServerMode from pymongo.server_description import ServerDescription - from pymongo.synchronous.mongo_client import MongoClient, _MongoClientErrorHandler + from pymongo.synchronous.mongo_client import _MongoClientErrorHandler from pymongo.synchronous.monitor import Monitor from pymongo.synchronous.pool import Connection, Pool - from pymongo.typings import _DocumentOut _IS_SYNC = True -_CURSOR_DOC_FIELDS = {"cursor": {"firstBatch": 1, "nextBatch": 1}} - class Server: def __init__( @@ -68,12 +53,8 @@ def __init__( self._description = server_description self._pool = pool self._monitor = monitor - self._topology_id = topology_id - self._publish = listeners is not None and listeners.enabled_for_server - self._listener = listeners - self._events = None - if self._publish: - self._events = events() # type: ignore[misc] + _events = events() if listeners is not None and listeners.enabled_for_server else None # type: ignore[misc] + self._sdam = _SdamTelemetry(topology_id, listeners, _events) # type: ignore[arg-type] def open(self) -> None: """Start monitoring, or restart after a fork. @@ -92,23 +73,7 @@ def close(self) -> None: Reconnect with open(). """ - if self._publish: - assert self._listener is not None - assert self._events is not None - self._events.put( - ( - self._listener.publish_server_closed, - (self._description.address, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.STOP_SERVER, - topologyId=self._topology_id, - serverHost=self._description.address[0], - serverPort=self._description.address[1], - ) + self._sdam.server_closed(self._description.address) self._monitor.close() self._pool.close() @@ -117,112 +82,6 @@ def request_check(self) -> None: """Check the server's state soon.""" self._monitor.request_check() - def operation_to_command( - self, operation: Union[_Query, _GetMore], conn: Connection, apply_timeout: bool = False - ) -> tuple[dict[str, Any], str]: - cmd, db = operation.as_command(conn, apply_timeout) - # Support auto encryption - if operation.client._encrypter and not operation.client._encrypter._bypass_auto_encryption: - cmd = operation.client._encrypter.encrypt( # type: ignore[misc, assignment] - operation.db, cmd, operation.codec_options - ) - operation.update_command(cmd) - - return cmd, db - - @_handle_reauth - def run_operation( - self, - conn: Connection, - operation: Union[_Query, _GetMore], - read_preference: _ServerMode, - listeners: Optional[_EventListeners], - unpack_res: Callable[..., list[_DocumentOut]], - client: MongoClient[Any], - ) -> Response: - """Run a _Query or _GetMore operation and return a Response object. - - This method is used only to run _Query/_GetMore operations from - cursors. - Can raise ConnectionFailure, OperationFailure, etc. - - :param conn: A Connection instance. - :param operation: A _Query or _GetMore object. - :param read_preference: The read preference to use. - :param listeners: Instance of _EventListeners or None. - :param unpack_res: A callable that decodes the wire protocol response. - :param client: A MongoClient instance. - """ - assert listeners is not None - - use_cmd = operation.use_command(conn) - more_to_come = bool(operation.conn_mgr and operation.conn_mgr.more_to_come) - cmd, dbn = self.operation_to_command(operation, conn, use_cmd) - if more_to_come: - request_id = 0 - data = b"" - max_doc_size = 0 - else: - message = operation.get_message(read_preference, conn, use_cmd) - request_id, data, max_doc_size = self._split_message(message) - - user_fields = _CURSOR_DOC_FIELDS if use_cmd else None - - docs, reply, duration = run_cursor_command( - conn, - cmd, - dbn, - request_id, - data, - client=client, - session=operation.session, # type: ignore[arg-type] - listeners=listeners, - codec_options=operation.codec_options, - user_fields=user_fields, - command_name=operation.name, - pool_opts=conn.opts, - max_doc_size=max_doc_size, - more_to_come=more_to_come, - unpack_res=unpack_res, - cursor_id=operation.cursor_id, - ) - assert reply is not None - - response: Response - - if client._should_pin_cursor(operation.session) or operation.exhaust: # type: ignore[arg-type] - conn.pin_cursor() - if isinstance(reply, _OpMsg): - # In OP_MSG, the server keeps sending only if the - # more_to_come flag is set. - more_to_come = reply.more_to_come - else: - # In OP_REPLY, the server keeps sending until cursor_id is 0. - more_to_come = bool(operation.exhaust and reply.cursor_id) - if operation.conn_mgr: - operation.conn_mgr.update_exhaust(more_to_come) - response = PinnedResponse( - data=reply, - address=self._description.address, - conn=conn, - duration=duration, - request_id=request_id, - from_command=use_cmd, - docs=docs, # type: ignore[arg-type] - more_to_come=more_to_come, - ) - else: - response = Response( - data=reply, - address=self._description.address, - duration=duration, - request_id=request_id, - from_command=use_cmd, - docs=docs, # type: ignore[arg-type] - ) - - return response - def checkout( self, handler: Optional[_MongoClientErrorHandler] = None ) -> AbstractContextManager[Connection]: @@ -241,19 +100,5 @@ def description(self, server_description: ServerDescription) -> None: def pool(self) -> Pool: return self._pool - def _split_message( - self, message: Union[tuple[int, Any], tuple[int, Any, int]] - ) -> tuple[int, Any, int]: - """Return request_id, data, max_doc_size. - - :param message: (request_id, data, max_doc_size) or (request_id, data) - """ - if len(message) == 3: - return message # type: ignore[return-value] - else: - # get_more and kill_cursors messages don't include BSON documents. - request_id, data = message # type: ignore[misc] - return request_id, data, 0 - def __repr__(self) -> str: return f"<{self.__class__.__name__} {self._description!r}>" diff --git a/pymongo/synchronous/topology.py b/pymongo/synchronous/topology.py index b419833256..ff442905fe 100644 --- a/pymongo/synchronous/topology.py +++ b/pymongo/synchronous/topology.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -import logging import os import queue import random @@ -30,6 +29,11 @@ from typing import TYPE_CHECKING, Any, Callable, Optional, cast from pymongo import _csot, common, helpers_shared, periodic_executor +from pymongo._telemetry import ( + _SdamTelemetry, + _ServerSelectionTelemetry, + log_server_selection_succeeded, +) from pymongo.errors import ( ConnectionFailure, InvalidOperation, @@ -47,13 +51,6 @@ _create_condition, _create_lock, ) -from pymongo.logger import ( - _SDAM_LOGGER, - _SERVER_SELECTION_LOGGER, - _debug_log, - _SDAMStatusMessage, - _ServerSelectionStatusMessage, -) from pymongo.pool_options import PoolOptions from pymongo.server_description import ServerDescription from pymongo.server_selectors import ( @@ -108,27 +105,19 @@ class Topology: def __init__(self, topology_settings: TopologySettings): self._topology_id = topology_settings._topology_id self._listeners = topology_settings._pool_options._event_listeners - self._publish_server = self._listeners is not None and self._listeners.enabled_for_server - self._publish_tp = self._listeners is not None and self._listeners.enabled_for_topology # Create events queue if there are publishers. self._events: queue.Queue[Any] | None = None self.__events_executor: Any = None - if self._publish_server or self._publish_tp: + publish_server = self._listeners is not None and self._listeners.enabled_for_server + publish_tp = self._listeners is not None and self._listeners.enabled_for_topology + if publish_server or publish_tp: self._events = queue.Queue(maxsize=100) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.START_TOPOLOGY, - topologyId=self._topology_id, - ) + self._sdam = _SdamTelemetry(self._topology_id, self._listeners, self._events) + self._sdam.topology_opened() - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put((self._listeners.publish_topology_opened, (self._topology_id,))) self._settings = topology_settings topology_description = TopologyDescription( topology_settings.get_topology_type(), @@ -143,37 +132,10 @@ def __init__(self, topology_settings: TopologySettings): initial_td = TopologyDescription( TOPOLOGY_TYPE.Unknown, {}, None, None, None, self._settings ) - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (initial_td, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(initial_td), - newDescription=repr(self._description), - ) + self._sdam.topology_description_changed(initial_td, self._description) for seed in topology_settings.seeds: - if self._publish_server: - assert self._events is not None - assert self._listeners is not None - self._events.put((self._listeners.publish_server_opened, (seed, self._topology_id))) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.START_SERVER, - topologyId=self._topology_id, - serverHost=seed[0], - serverPort=seed[1], - ) + self._sdam.server_opened(seed) # Store the seed list to help diagnose errors in _error_message(). self._seed_addresses = list(topology_description.server_descriptions()) @@ -188,7 +150,7 @@ def __init__(self, topology_settings: TopologySettings): self._max_cluster_time: Optional[ClusterTime] = None self._session_pool = _ServerSessionPool() - if self._publish_server or self._publish_tp: + if self._sdam._publish_server or self._sdam._publish_tp: assert self._events is not None weak: weakref.ReferenceType[queue.Queue[Any]] @@ -321,17 +283,10 @@ def _select_servers_loop( now = time.monotonic() end_time = now + timeout logged_waiting = False - - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.STARTED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - ) + ss = _ServerSelectionTelemetry( + self._topology_id, selector, operation, operation_id, self.description + ) + ss.started() server_descriptions = self._description.apply_selector( selector, @@ -345,32 +300,13 @@ def _select_servers_loop( while not server_descriptions: # No suitable servers. if timeout == 0 or now > end_time: - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.FAILED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - failure=self._error_message(selector), - ) + ss.failed(self._error_message(selector), self.description) raise ServerSelectionTimeoutError( f"{self._error_message(selector)}, Timeout: {timeout}s, Topology Description: {self.description!r}" ) if not logged_waiting: - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.WAITING, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - remainingTimeMS=int(1000 * (end_time - time.monotonic())), - ) + ss.waiting(int(1000 * (end_time - time.monotonic()))) logged_waiting = True self._ensure_opened() @@ -435,18 +371,15 @@ def select_server( ) if _csot.get_timeout(): _csot.set_rtt(server.description.min_round_trip_time) - if _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SERVER_SELECTION_LOGGER, - message=_ServerSelectionStatusMessage.SUCCEEDED, - selector=selector, - operation=operation, - operationId=operation_id, - topologyDescription=self.description, - clientId=self.description._topology_settings._topology_id, - serverHost=server.description.address[0], - serverPort=server.description.address[1], - ) + log_server_selection_succeeded( + self._topology_id, + selector, + operation, + operation_id, + self.description, + server.description.address[0], + server.description.address[1], + ) return server def select_server_by_address( @@ -508,36 +441,16 @@ def _process_change( server.pool.ready() suppress_event = sd_old == server_description - if self._publish_server and not suppress_event: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_server_description_changed, - (sd_old, server_description, server_description.address, self._topology_id), - ) + if not suppress_event: + self._sdam.server_description_changed( + sd_old, server_description, server_description.address ) self._description = new_td self._update_servers() - if self._publish_tp and not suppress_event: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (td_old, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG) and not suppress_event: - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(td_old), - newDescription=repr(self._description), - ) + if not suppress_event: + self._sdam.topology_description_changed(td_old, self._description) # Shutdown SRV polling for unsupported cluster types. # This is only applicable if the old topology was Unknown, and the @@ -588,24 +501,7 @@ def _process_srv_update(self, seedlist: list[tuple[str, Any]]) -> None: self._description = _updated_topology_description_srv_polling(self._description, seedlist) self._update_servers() - - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None - self._events.put( - ( - self._listeners.publish_topology_description_changed, - (td_old, self._description, self._topology_id), - ) - ) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(td_old), - newDescription=repr(self._description), - ) + self._sdam.topology_description_changed(td_old, self._description) def on_srv_update(self, seedlist: list[tuple[str, Any]]) -> None: """Process a new list of nodes obtained from scanning SRV records.""" @@ -741,9 +637,7 @@ def close(self) -> None: self._closed = True # Publish only after releasing the lock. - if self._publish_tp: - assert self._events is not None - assert self._listeners is not None + if self._sdam._publish_tp: self._description = TopologyDescription( TOPOLOGY_TYPE.Unknown, {}, @@ -752,30 +646,9 @@ def close(self) -> None: self._description.max_election_id, self._description._topology_settings, ) - self._events.put( - ( - self._listeners.publish_topology_description_changed, - ( - old_td, - self._description, - self._topology_id, - ), - ) - ) - self._events.put((self._listeners.publish_topology_closed, (self._topology_id,))) - if _SDAM_LOGGER.isEnabledFor(logging.DEBUG): - _debug_log( - _SDAM_LOGGER, - message=_SDAMStatusMessage.TOPOLOGY_CHANGE, - topologyId=self._topology_id, - previousDescription=repr(old_td), - newDescription=repr(self._description), - ) - _debug_log( - _SDAM_LOGGER, message=_SDAMStatusMessage.STOP_TOPOLOGY, topologyId=self._topology_id - ) + self._sdam.topology_closed(old_td, self._description) - if self._publish_server or self._publish_tp: + if self._sdam._publish_server or self._sdam._publish_tp: # Make sure the events executor thread is fully closed before publishing the remaining events self.__events_executor.close() self.__events_executor.join(1) @@ -816,7 +689,7 @@ def _ensure_opened(self) -> None: self._update_servers() # Start or restart the events publishing thread. - if self._publish_tp or self._publish_server: + if self._sdam._publish_tp or self._sdam._publish_server: self.__events_executor.open() # Start the SRV polling thread. @@ -955,7 +828,7 @@ def _update_servers(self) -> None: ) weak = None - if self._publish_server and self._events is not None: + if self._sdam._publish_server and self._events is not None: weak = weakref.ref(self._events) server = Server( server_description=sd, diff --git a/pyproject.toml b/pyproject.toml index 6b439c5678..5922a4aa2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ Tracker = "https://jira.mongodb.org/projects/PYTHON/issues" [dependency-groups] dev = [] lint = [ - "ruff==0.15.18", + "ruff==0.15.20", ] unasync = [ {include-group = "lint"}, @@ -66,7 +66,7 @@ mockupdb = [ perf = ["simplejson>=3.17.0"] typing = [ "mypy==1.19.1", - "pyright==1.1.410", + "pyright==1.1.411", "typing_extensions>=3.7.4.2", "pip>=20.2" ] diff --git a/requirements/ocsp.txt b/requirements/ocsp.txt index 083ffcb102..a95311d894 100644 --- a/requirements/ocsp.txt +++ b/requirements/ocsp.txt @@ -10,5 +10,5 @@ certifi>=2023.7.22;os.name=='nt' or sys_platform=='darwin' pyopenssl>=26.2.0 requests>=2.23.0,<3.0 -cryptography>=42.0.0 +cryptography>=47.0.0 service_identity>=24.2.0 diff --git a/test/asynchronous/test_async_network_layer.py b/test/asynchronous/test_async_network_layer.py new file mode 100644 index 0000000000..5adb7aaeac --- /dev/null +++ b/test/asynchronous/test_async_network_layer.py @@ -0,0 +1,177 @@ +# Copyright 2026-present MongoDB, 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 +# +# https://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. + +"""Async-only unit tests for network_layer.py.""" + +from __future__ import annotations + +import asyncio +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +sys.path[0:0] = [""] + +from pymongo.common import MAX_MESSAGE_SIZE +from pymongo.errors import ProtocolError +from pymongo.network_layer import PyMongoProtocol, _async_socket_receive +from test.asynchronous import AsyncUnitTest, unittest +from test.utils_shared import pack_msg_header + + +def _make_protocol(timeout=None): + protocol = PyMongoProtocol(timeout=timeout) + mock_transport = MagicMock() + mock_transport.is_closing.return_value = False + protocol.transport = mock_transport + return protocol + + +class TestProcessHeader(AsyncUnitTest): + async def asyncSetUp(self): + self.protocol = _make_protocol() + + def test_op_msg_returns_body_len_and_op_code(self): + self.protocol._header = memoryview( + bytearray(pack_msg_header(length=32, request_id=1, response_to=99, op_code=2013)) + ) + body_len, op_code, response_to, expecting_compression = self.protocol.process_header() + self.assertEqual(body_len, 16) + self.assertEqual(op_code, 2013) + self.assertEqual(response_to, 99) + self.assertFalse(expecting_compression) + + def test_op_compressed_sets_expecting_compression(self): + # OP_COMPRESSED=2012; process_header strips the 9-byte compression sub-header + # (op code + uncompressed size + compressor id), then the 16-byte standard header. + # length=35 → after compression sub-header: 26 → body: 10 + self.protocol._header = memoryview( + bytearray(pack_msg_header(length=35, request_id=1, response_to=0, op_code=2012)) + ) + body_len, op_code, _response_to, expecting_compression = self.protocol.process_header() + self.assertEqual(body_len, 10) + self.assertEqual(op_code, 2012) + self.assertTrue(expecting_compression) + + def test_op_compressed_length_too_small_raises(self): + self.protocol._header = memoryview( + bytearray(pack_msg_header(length=25, request_id=1, response_to=0, op_code=2012)) + ) + with self.assertRaisesRegex(ProtocolError, "not longer than standard OP_COMPRESSED"): + self.protocol.process_header() + + def test_non_compressed_length_too_small_raises(self): + self.protocol._header = memoryview( + bytearray(pack_msg_header(length=16, request_id=1, response_to=0, op_code=2013)) + ) + with self.assertRaisesRegex(ProtocolError, "not longer than standard message header size"): + self.protocol.process_header() + + def test_length_exceeds_max_raises(self): + self.protocol._header = memoryview( + bytearray( + pack_msg_header( + length=MAX_MESSAGE_SIZE + 1, request_id=1, response_to=0, op_code=2013 + ) + ) + ) + with self.assertRaisesRegex(ProtocolError, "larger than server max"): + self.protocol.process_header() + + +class TestClose(AsyncUnitTest): + async def asyncSetUp(self): + self.protocol = _make_protocol() + + def test_close_aborts_transport(self): + self.protocol.close() + self.assertTrue(self.protocol.transport.abort.called) + + async def test_close_propagates_exception_to_pending_read(self): + read_task = asyncio.create_task( + self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE) + ) + await asyncio.sleep(0) + self.protocol.close(OSError("connection reset")) + with self.assertRaisesRegex(OSError, "connection reset"): + await read_task + + +class TestBufferUpdated(AsyncUnitTest): + async def asyncSetUp(self): + self.protocol = _make_protocol() + + async def test_zero_bytes_closes_connection(self): + # A zero-byte buffer_updated (connection closed mid-read) must surface to + # a waiting reader as OSError("connection closed"), not just abort the transport. + read_task = asyncio.create_task( + self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE) + ) + await asyncio.sleep(0) + self.protocol.buffer_updated(0) + self.assertTrue(self.protocol.transport.abort.called) + with self.assertRaisesRegex(OSError, "connection closed"): + await read_task + + async def test_protocol_error_closes_connection(self): + # A malformed header must surface to a waiting reader as a ProtocolError, + # not just abort the transport. + read_task = asyncio.create_task( + self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE) + ) + await asyncio.sleep(0) + buf = self.protocol.get_buffer(16) + buf[:16] = pack_msg_header(length=16, request_id=1, response_to=0, op_code=2013) + self.protocol.buffer_updated(16) + self.assertTrue(self.protocol.transport.abort.called) + with self.assertRaisesRegex(ProtocolError, "not longer than standard message header"): + await read_task + + async def test_resolves_pending_read(self): + read_task = asyncio.create_task( + self.protocol.read(request_id=None, max_message_size=MAX_MESSAGE_SIZE) + ) + await asyncio.sleep(0) + + # Feed a valid 32-byte OP_MSG header (16-byte header + 16-byte body). + header = pack_msg_header(length=32, request_id=1, response_to=99, op_code=2013) + buf = self.protocol.get_buffer(16) + buf[:16] = header + self.protocol.buffer_updated(16) + + self.assertFalse(self.protocol._expecting_header) + self.assertEqual(self.protocol._message_size, 16) + + # Feed the 16-byte body. + buf = self.protocol.get_buffer(16) + buf[:16] = b"x" * 16 + self.protocol.buffer_updated(16) + + _data, op_code = await read_task + self.assertEqual(op_code, 2013) + + +class TestAsyncSocketReceive(AsyncUnitTest): + async def test_raises_on_connection_closed(self): + # Covers the explicit `raise OSError("connection closed")` branch when + # sock_recv_into returns 0. + mock_socket = MagicMock() + loop = asyncio.get_running_loop() + + with patch.object(loop, "sock_recv_into", new=AsyncMock(return_value=0)): + with self.assertRaisesRegex(OSError, "connection closed"): + await _async_socket_receive(mock_socket, 10, loop) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/asynchronous/test_auth_oidc.py b/test/asynchronous/test_auth_oidc.py index 1eb3393f2b..669537ab50 100644 --- a/test/asynchronous/test_auth_oidc.py +++ b/test/asynchronous/test_auth_oidc.py @@ -117,6 +117,26 @@ async def fail_point(self, command_args): await client.close() +class TestOIDCAllowedHostsCache(unittest.TestCase): + class HumanCallback(OIDCCallback): + def fetch(self, context): + return OIDCCallbackResult(access_token="token") + + def test_allowed_hosts_checked_before_cached_authenticator_reuse(self): + props = { + "OIDC_HUMAN_CALLBACK": self.HumanCallback(), + "ALLOWED_HOSTS": ["good.example.com"], + } + extra = {"authmechanismproperties": props} + credentials = _build_credentials_tuple("MONGODB-OIDC", None, "user", None, extra, "test") + + authenticator = _get_authenticator(credentials, ("good.example.com", 27017)) + self.assertIs(authenticator, credentials.cache.data) + + with self.assertRaisesRegex(ConfigurationError, "evil.example.com"): + _get_authenticator(credentials, ("evil.example.com", 27017)) + + class TestAuthOIDCHuman(OIDCTestBase): uri: str diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 5da186931a..a7f66d9620 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -1676,7 +1676,7 @@ async def test_stale_getmore(self): False, None, ), - unpack_res=AsyncCursor(client.pymongo_test.collection)._unpack_response, + run_with_conn=AsyncCursor(client.pymongo_test.collection)._run_with_conn, address=("not-a-member", 27017), ) diff --git a/test/asynchronous/test_discovery_and_monitoring.py b/test/asynchronous/test_discovery_and_monitoring.py index 6867a12060..6fc0e09a19 100644 --- a/test/asynchronous/test_discovery_and_monitoring.py +++ b/test/asynchronous/test_discovery_and_monitoring.py @@ -467,26 +467,41 @@ async def test_connection_pool_is_not_cleared(self): # Create a client that listens to CMAP events, with maxConnecting=100. client = await self.async_rs_or_single_client(maxConnecting=100, event_listeners=[listener]) - # Enable the ingress rate limiter. - await client.admin.command( - "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=True - ) - await client.admin.command("setParameter", 1, ingressConnectionEstablishmentRatePerSec=20) - await client.admin.command( - "setParameter", 1, ingressConnectionEstablishmentBurstCapacitySecs=1 - ) - await client.admin.command("setParameter", 1, ingressConnectionEstablishmentMaxQueueDepth=1) + # setParameter needs to be set on each mongos in a sharded cluster + if async_client_context.mongoses: + admin_clients = [ + await self.async_single_client("{}:{}".format(*address)) + for address in async_client_context.mongoses + ] + else: + admin_clients = [client] # Disable the ingress rate limiter on teardown. # Sleep for 1 second before disabling to avoid the rate limiter. async def teardown(): await asyncio.sleep(1) - await client.admin.command( - "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=False - ) + for admin_client in admin_clients: + await admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=False + ) self.addAsyncCleanup(teardown) + # Enable the ingress rate limiter. + for admin_client in admin_clients: + await admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=True + ) + await admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRatePerSec=20 + ) + await admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentBurstCapacitySecs=1 + ) + await admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentMaxQueueDepth=1 + ) + # Make sure the collection has at least one document. await client.test.test.delete_many({}) await client.test.test.insert_one({}) diff --git a/test/asynchronous/test_dns.py b/test/asynchronous/test_dns.py index b915653571..3e4e073013 100644 --- a/test/asynchronous/test_dns.py +++ b/test/asynchronous/test_dns.py @@ -293,7 +293,7 @@ async def test_4_throw_when_return_address_does_not_contain_dot_separating_share ] await self.run_initial_dns_seedlist_discovery_prose_tests(test_cases) - async def test_5_when_srv_hostname_has_two_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( + async def test_5_when_srv_hostname_has_three_or_more_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( self, ): test_cases = [ diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index 57acf5824e..f9094131d6 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -3314,6 +3314,7 @@ async def test_collection_name_collision(self): # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption +@unittest.skip("PYTHON-5799 need to add support for the new query type") class TestExplicitTextEncryptionProse(AsyncEncryptionIntegrationTest): @async_client_context.require_no_standalone @async_client_context.require_version_min(8, 2, -1) diff --git a/test/asynchronous/test_server_selection_in_window.py b/test/asynchronous/test_server_selection_in_window.py index 24a5789733..b631cebf68 100644 --- a/test/asynchronous/test_server_selection_in_window.py +++ b/test/asynchronous/test_server_selection_in_window.py @@ -29,6 +29,7 @@ from pymongo.read_preferences import ReadPreference from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest from test.asynchronous.helpers import ConcurrentRunner +from test.asynchronous.utils import flaky from test.asynchronous.utils_selection_tests import create_topology from test.asynchronous.utils_spec_runner import AsyncSpecTestCreator from test.utils_shared import ( @@ -144,6 +145,7 @@ async def frequencies(self, client, listener, n_finds=10): sys.platform == "darwin" and platform.machine() == "arm64" and "CI" in os.environ, "PYTHON-5861: Load balancing frequency assertion is timing-sensitive on macOS ARM64 CI", ) + @flaky(reason="PYTHON-5911", affects_cpython_linux=True) async def test_load_balancing(self): listener = OvertCommandListener() cmap_listener = CMAPListener() diff --git a/test/asynchronous/utils.py b/test/asynchronous/utils.py index 5842224220..32e8c89502 100644 --- a/test/asynchronous/utils.py +++ b/test/asynchronous/utils.py @@ -49,12 +49,8 @@ async def async_get_pool(client: AsyncMongoClient) -> Pool: async def async_get_pools(client: AsyncMongoClient) -> list[Pool]: """Get all pools.""" - return [ - server.pool - for server in await (await client._get_topology()).select_servers( - any_server_selector, _Op.TEST - ) - ] + servers = await (await client._get_topology()).select_servers(any_server_selector, _Op.TEST) + return [server.pool for server in servers] async def async_wait_until(predicate, success_description, timeout=10): diff --git a/test/client-side-encryption/spec/unified/QE-Text-substringPreview.json b/test/client-side-encryption/spec/unified/QE-Text-substringPreview.json index 7787194fc6..0cbfbbdfc6 100644 --- a/test/client-side-encryption/spec/unified/QE-Text-substringPreview.json +++ b/test/client-side-encryption/spec/unified/QE-Text-substringPreview.json @@ -4,6 +4,7 @@ "runOnRequirements": [ { "minServerVersion": "8.2.0", + "maxServerVersion": "8.99.99", "topologies": [ "replicaset", "sharded", diff --git a/test/srv_seedlist/replica-set/identical-hostnames-valid-four-parts.json b/test/srv_seedlist/replica-set/identical-hostnames-valid-four-parts.json new file mode 100644 index 0000000000..b28a9276b1 --- /dev/null +++ b/test/srv_seedlist/replica-set/identical-hostnames-valid-four-parts.json @@ -0,0 +1,8 @@ +{ + "uri": "mongodb+srv://test.build.10gen.cc/", + "seeds": [ + "test.build.10gen.cc:27017" + ], + "ping": false, + "comment": "Should not throw when SRV hostname and resolved hostname are identical and SRV hostname has four dot-separated parts." +} diff --git a/test/srv_seedlist/replica-set/identical-hostnames-valid-three-parts.json b/test/srv_seedlist/replica-set/identical-hostnames-valid-three-parts.json new file mode 100644 index 0000000000..7a578ab4cc --- /dev/null +++ b/test/srv_seedlist/replica-set/identical-hostnames-valid-three-parts.json @@ -0,0 +1,8 @@ +{ + "uri": "mongodb+srv://build.10gen.cc/", + "seeds": [ + "build.10gen.cc:27017" + ], + "ping": false, + "comment": "Should not throw when SRV hostname and resolved hostname are identical and SRV hostname has three dot-separated parts." +} diff --git a/test/test_auth_oidc.py b/test/test_auth_oidc.py index 30ac60ec6c..de4076b31a 100644 --- a/test/test_auth_oidc.py +++ b/test/test_auth_oidc.py @@ -117,6 +117,26 @@ def fail_point(self, command_args): client.close() +class TestOIDCAllowedHostsCache(unittest.TestCase): + class HumanCallback(OIDCCallback): + def fetch(self, context): + return OIDCCallbackResult(access_token="token") + + def test_allowed_hosts_checked_before_cached_authenticator_reuse(self): + props = { + "OIDC_HUMAN_CALLBACK": self.HumanCallback(), + "ALLOWED_HOSTS": ["good.example.com"], + } + extra = {"authmechanismproperties": props} + credentials = _build_credentials_tuple("MONGODB-OIDC", None, "user", None, extra, "test") + + authenticator = _get_authenticator(credentials, ("good.example.com", 27017)) + self.assertIs(authenticator, credentials.cache.data) + + with self.assertRaisesRegex(ConfigurationError, "evil.example.com"): + _get_authenticator(credentials, ("evil.example.com", 27017)) + + class TestAuthOIDCHuman(OIDCTestBase): uri: str diff --git a/test/test_client.py b/test/test_client.py index b37b5e57ac..8f0da71321 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -1633,7 +1633,7 @@ def test_stale_getmore(self): False, None, ), - unpack_res=Cursor(client.pymongo_test.collection)._unpack_response, + run_with_conn=Cursor(client.pymongo_test.collection)._run_with_conn, address=("not-a-member", 27017), ) diff --git a/test/test_discovery_and_monitoring.py b/test/test_discovery_and_monitoring.py index 1f2c21f9c1..612d52afbf 100644 --- a/test/test_discovery_and_monitoring.py +++ b/test/test_discovery_and_monitoring.py @@ -465,24 +465,40 @@ def test_connection_pool_is_not_cleared(self): # Create a client that listens to CMAP events, with maxConnecting=100. client = self.rs_or_single_client(maxConnecting=100, event_listeners=[listener]) - # Enable the ingress rate limiter. - client.admin.command( - "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=True - ) - client.admin.command("setParameter", 1, ingressConnectionEstablishmentRatePerSec=20) - client.admin.command("setParameter", 1, ingressConnectionEstablishmentBurstCapacitySecs=1) - client.admin.command("setParameter", 1, ingressConnectionEstablishmentMaxQueueDepth=1) + # setParameter needs to be set on each mongos in a sharded cluster + if client_context.mongoses: + admin_clients = [ + self.single_client("{}:{}".format(*address)) for address in client_context.mongoses + ] + else: + admin_clients = [client] # Disable the ingress rate limiter on teardown. # Sleep for 1 second before disabling to avoid the rate limiter. def teardown(): time.sleep(1) - client.admin.command( - "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=False - ) + for admin_client in admin_clients: + admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=False + ) self.addCleanup(teardown) + # Enable the ingress rate limiter. + for admin_client in admin_clients: + admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRateLimiterEnabled=True + ) + admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentRatePerSec=20 + ) + admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentBurstCapacitySecs=1 + ) + admin_client.admin.command( + "setParameter", 1, ingressConnectionEstablishmentMaxQueueDepth=1 + ) + # Make sure the collection has at least one document. client.test.test.delete_many({}) client.test.test.insert_one({}) diff --git a/test/test_dns.py b/test/test_dns.py index 09933b2e27..d4d3074ea5 100644 --- a/test/test_dns.py +++ b/test/test_dns.py @@ -291,7 +291,7 @@ def test_4_throw_when_return_address_does_not_contain_dot_separating_shared_part ] self.run_initial_dns_seedlist_discovery_prose_tests(test_cases) - def test_5_when_srv_hostname_has_two_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( + def test_5_when_srv_hostname_has_three_or_more_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( self, ): test_cases = [ diff --git a/test/test_encryption.py b/test/test_encryption.py index 3bb6e767b4..ba0236b42b 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -3296,6 +3296,7 @@ def test_collection_name_collision(self): # https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/tests/README.md#27-text-explicit-encryption +@unittest.skip("PYTHON-5799 need to add support for the new query type") class TestExplicitTextEncryptionProse(EncryptionIntegrationTest): @client_context.require_no_standalone @client_context.require_version_min(8, 2, -1) diff --git a/test/test_network_layer.py b/test/test_network_layer.py new file mode 100644 index 0000000000..1c2fd1e01c --- /dev/null +++ b/test/test_network_layer.py @@ -0,0 +1,105 @@ +# Copyright 2026-present MongoDB, 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 +# +# https://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. + +"""Sync-only unit tests for network_layer.py. + +These cover ``receive_message`` and ``receive_data``, which only exist on the +synchronous receive path (the async path uses ``PyMongoProtocol`` instead). +The async-only tests live in ``test/asynchronous/test_async_network_layer.py``. +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock + +sys.path[0:0] = [""] + +from pymongo import network_layer +from pymongo.common import MAX_MESSAGE_SIZE +from pymongo.errors import ProtocolError +from test import UnitTest, unittest +from test.utils_shared import pack_msg_header + + +def _make_conn(): + conn = MagicMock() + conn.conn.gettimeout.return_value = None + # On PyPy/Windows, receive_data() calls wait_for_read() before recv_into(). + # wait_for_read() checks fileno() == -1 as an early-exit; without this mock, + # sock.fileno() returns a MagicMock and sock.pending() > 0 raises TypeError. + conn.conn.sock.fileno.return_value = -1 + return conn + + +def _mock_recv_into(conn, *chunks: bytes) -> None: + # Scope the mock to this conn's recv_into to avoid races + # with SDAM monitor threads owned by the shared test client + it = iter(chunks) + + def _recv_into(buf) -> int: + chunk = next(it) + buf[: len(chunk)] = chunk + return len(chunk) + + conn.conn.recv_into.side_effect = _recv_into + + +class TestReceiveMessage(UnitTest): + def test_request_id_mismatch_raises(self): + conn = _make_conn() + _mock_recv_into( + conn, pack_msg_header(length=32, request_id=0, response_to=99, op_code=2013) + ) + with self.assertRaisesRegex(ProtocolError, "Got response id"): + network_layer.receive_message(conn, request_id=1) + + def test_length_too_small_raises(self): + conn = _make_conn() + _mock_recv_into(conn, pack_msg_header(length=16, request_id=0, response_to=0, op_code=2013)) + with self.assertRaisesRegex(ProtocolError, "not longer than standard message header"): + network_layer.receive_message(conn, request_id=None) + + def test_length_exceeds_max_raises(self): + conn = _make_conn() + _mock_recv_into( + conn, + pack_msg_header(length=MAX_MESSAGE_SIZE + 1, request_id=0, response_to=0, op_code=2013), + ) + with self.assertRaisesRegex(ProtocolError, "larger than server max"): + network_layer.receive_message(conn, request_id=None) + + def test_unknown_opcode_raises(self): + conn = _make_conn() + _mock_recv_into( + conn, + pack_msg_header(length=20, request_id=0, response_to=0, op_code=9999), + b"data", + ) + with self.assertRaisesRegex(ProtocolError, "Got opcode"): + network_layer.receive_message(conn, request_id=None) + + +class TestReceiveData(UnitTest): + def test_raises_on_connection_closed(self): + # Covers the explicit `raise OSError("connection closed")` branch when + # recv_into returns 0. + conn = _make_conn() + conn.conn.recv_into.return_value = 0 + with self.assertRaisesRegex(OSError, "connection closed"): + network_layer.receive_data(conn, 10, deadline=None) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_server_selection_in_window.py b/test/test_server_selection_in_window.py index 547965095c..7b561d93db 100644 --- a/test/test_server_selection_in_window.py +++ b/test/test_server_selection_in_window.py @@ -29,6 +29,7 @@ from pymongo.read_preferences import ReadPreference from test import IntegrationTest, client_context, unittest from test.helpers import ConcurrentRunner +from test.utils import flaky from test.utils_selection_tests import create_topology from test.utils_shared import ( CMAPListener, @@ -144,6 +145,7 @@ def frequencies(self, client, listener, n_finds=10): sys.platform == "darwin" and platform.machine() == "arm64" and "CI" in os.environ, "PYTHON-5861: Load balancing frequency assertion is timing-sensitive on macOS ARM64 CI", ) + @flaky(reason="PYTHON-5911", affects_cpython_linux=True) def test_load_balancing(self): listener = OvertCommandListener() cmap_listener = CMAPListener() diff --git a/test/utils.py b/test/utils.py index 54b7e351e3..2e67008431 100644 --- a/test/utils.py +++ b/test/utils.py @@ -49,10 +49,8 @@ def get_pool(client: MongoClient) -> Pool: def get_pools(client: MongoClient) -> list[Pool]: """Get all pools.""" - return [ - server.pool - for server in (client._get_topology()).select_servers(any_server_selector, _Op.TEST) - ] + servers = (client._get_topology()).select_servers(any_server_selector, _Op.TEST) + return [server.pool for server in servers] def wait_until(predicate, success_description, timeout=10): diff --git a/test/utils_shared.py b/test/utils_shared.py index db6ff9aacb..c0c811a5a7 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -23,6 +23,7 @@ import random import re import shutil +import struct import sys import threading import unittest @@ -743,3 +744,13 @@ async def async_barrier_wait(barrier, timeout: float | None = None): def barrier_wait(barrier, timeout: float | None = None): barrier.wait(timeout=timeout) + + +def pack_msg_header(length: int, request_id: int, response_to: int, op_code: int) -> bytes: + """Pack a MongoDB wire-protocol message header (`` set[str]: - result: set[str] = set() - for x in async_classes: - methods = { - k - for k, v in vars(x).items() - if callable(v) - and not isinstance(v, classmethod) - and inspect.iscoroutinefunction(v) - and v.__name__[0] != "_" - } - result = result | methods - return result - - -async_methods = get_async_methods() - - -def apply_replacements(lines: list[str]) -> list[str]: - for i in range(len(lines)): - if "_IS_SYNC = True" in lines[i]: - lines[i] = "_IS_SYNC = False" - if "def test" in lines[i]: - lines[i] = lines[i].replace("def test", "async def test") - for k, value in replacements.items(): - if k in lines[i]: - lines[i] = lines[i].replace(k, value) - for k in async_methods: - if k + "(" in lines[i]: - tokens = lines[i].split(" ") - for j in range(len(tokens)): - if k + "(" in tokens[j]: - if j < 2: - tokens.insert(0, "await") - else: - tokens.insert(j, "await") - break - new_line = " ".join(tokens) - - lines[i] = new_line - - return lines - - -def process_file(input_file: str, output_file: str) -> None: - with open(input_file, "r+") as f: - lines = f.readlines() - lines = apply_replacements(lines) - - with open(output_file, "w+") as f2: - f2.seek(0) - f2.writelines(lines) - f2.truncate() - - -def main() -> None: - args = sys.argv[1:] - sync_file = "./test/" + args[0] - async_file = "./" + args[0] - - process_file(sync_file, async_file) - - -main() diff --git a/tools/synchro.py b/tools/synchro.py index 8132167e71..f9c3f23c8c 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -189,6 +189,7 @@ def async_only_test(f: Path) -> bool: "test_async_loop_safety.py", "test_async_contextvars_reset.py", "test_async_loop_unblocked.py", + "test_async_network_layer.py", ] @@ -198,91 +199,6 @@ def async_only_test(f: Path) -> bool: if f.is_file() and not async_only_test(f) ] -# Add each asynchronized test here as part of the converting PR -converted_tests = [ - "__init__.py", - "conftest.py", - "helpers.py", - "pymongo_mocks.py", - "utils_spec_runner.py", - "qcheck.py", - "test_auth.py", - "test_auth_oidc.py", - "test_auth_spec.py", - "test_bulk.py", - "test_causal_consistency.py", - "test_change_stream.py", - "test_client.py", - "test_client_backpressure.py", - "test_client_bulk_write.py", - "test_client_context.py", - "test_client_metadata.py", - "test_collation.py", - "test_collection.py", - "test_collection_management.py", - "test_command_logging.py", - "test_command_logging.py", - "test_command_monitoring.py", - "test_comment.py", - "test_common.py", - "test_connection_logging.py", - "test_connection_monitoring.py", - "test_connections_survive_primary_stepdown_spec.py", - "test_create_entities.py", - "test_crud_unified.py", - "test_csot.py", - "test_cursor.py", - "test_custom_types.py", - "test_database.py", - "test_discovery_and_monitoring.py", - "test_dns.py", - "test_encryption.py", - "test_examples.py", - "test_grid_file.py", - "test_gridfs.py", - "test_gridfs_bucket.py", - "test_gridfs_spec.py", - "test_handshake_unified.py", - "test_heartbeat_monitoring.py", - "test_index_management.py", - "test_json_util_integration.py", - "test_load_balancer.py", - "test_logger.py", - "test_max_staleness.py", - "test_monitor.py", - "test_monitoring.py", - "test_mongos_load_balancing.py", - "test_on_demand_csfle.py", - "test_periodic_executor.py", - "test_pooling.py", - "test_raw_bson.py", - "test_read_concern.py", - "test_read_preferences.py", - "test_read_write_concern_spec.py", - "test_retryable_reads.py", - "test_retryable_reads_unified.py", - "test_retryable_writes.py", - "test_retryable_writes_unified.py", - "test_run_command.py", - "test_sdam_monitoring_spec.py", - "test_server_selection.py", - "test_server_selection_in_window.py", - "test_server_selection_logging.py", - "test_server_selection_rtt.py", - "test_session.py", - "test_sessions_unified.py", - "test_srv_polling.py", - "test_ssl.py", - "test_streaming_protocol.py", - "test_transactions.py", - "test_transactions_unified.py", - "test_unified_format.py", - "test_versioned_api_integration.py", - "unified_format.py", - "utils_selection_tests.py", - "utils.py", -] - def process_files( files: list[str], docstring_translate_files: list[str], sync_test_files: list[str] @@ -466,13 +382,12 @@ def main() -> None: unasync_directory(filtered_tests, _test_base, _test_dest_base, replacements) # Derive generated output paths directly from filtered source paths. - converted_tests_set = set(converted_tests) generated_pymongo = [_pymongo_dest_base + Path(f).name for f in filtered_async] generated_gridfs = [_gridfs_dest_base + Path(f).name for f in filtered_gridfs] generated_tests = [ _test_dest_base + Path(f).name for f in filtered_tests - if Path(f).name in converted_tests_set and (Path(_test_dest_base) / Path(f).name).is_file() + if (Path(_test_dest_base) / Path(f).name).is_file() ] docstring_translate_files = generated_pymongo + generated_gridfs + generated_tests diff --git a/uv.lock b/uv.lock index f75ad964bf..fe1819e7f8 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.11'", "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] [[package]] @@ -24,7 +25,8 @@ name = "alabaster" version = "0.7.16" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } wheels = [ @@ -462,7 +464,8 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, @@ -724,67 +727,127 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "47.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version <= '3.9'", +] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version > '3.9' and python_full_version < '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +dependencies = [ + { name = "cffi", marker = "python_full_version > '3.9' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -801,7 +864,8 @@ name = "dnspython" version = "2.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } wheels = [ @@ -928,7 +992,8 @@ name = "greenlet" version = "3.2.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } wheels = [ @@ -1130,7 +1195,8 @@ name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ @@ -1544,8 +1610,10 @@ gssapi = [ ] ocsp = [ { name = "certifi", marker = "os_name == 'nt' or sys_platform == 'darwin'" }, - { name = "cryptography" }, - { name = "pyopenssl" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "49.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, + { name = "pyopenssl", version = "26.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "pyopenssl", version = "26.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "requests" }, { name = "service-identity" }, ] @@ -1598,7 +1666,7 @@ requires-dist = [ { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=1.0.0" }, { name = "certifi", marker = "(os_name == 'nt' and extra == 'encryption') or (sys_platform == 'darwin' and extra == 'encryption')", specifier = ">=2023.7.22" }, { name = "certifi", marker = "(os_name == 'nt' and extra == 'ocsp') or (sys_platform == 'darwin' and extra == 'ocsp')", specifier = ">=2023.7.22" }, - { name = "cryptography", marker = "extra == 'ocsp'", specifier = ">=42.0.0" }, + { name = "cryptography", marker = "extra == 'ocsp'", specifier = ">=47.0.0" }, { name = "dnspython", specifier = ">=2.7.0,<3.0.0" }, { name = "furo", marker = "extra == 'docs'", specifier = "==2025.12.19" }, { name = "importlib-metadata", marker = "python_full_version < '3.13' and extra == 'test'", specifier = ">=7.0" }, @@ -1625,18 +1693,18 @@ provides-extras = ["aws", "docs", "encryption", "gssapi", "ocsp", "snappy", "tes coverage = [{ name = "coverage", extras = ["toml"], specifier = ">=5,<=7.10.7" }] dev = [] gevent = [{ name = "gevent", specifier = ">=21.12" }] -lint = [{ name = "ruff", specifier = "==0.15.18" }] +lint = [{ name = "ruff", specifier = "==0.15.20" }] mockupdb = [{ name = "mockupdb", git = "https://github.com/mongodb-labs/mongo-mockup-db?rev=master" }] perf = [{ name = "simplejson", specifier = ">=3.17.0" }] pip = [{ name = "pip", specifier = ">=20.2" }] typing = [ { name = "mypy", specifier = "==1.19.1" }, { name = "pip", specifier = ">=20.2" }, - { name = "pyright", specifier = "==1.1.410" }, + { name = "pyright", specifier = "==1.1.411" }, { name = "typing-extensions", specifier = ">=3.7.4.2" }, ] unasync = [ - { name = "ruff", specifier = "==0.15.18" }, + { name = "ruff", specifier = "==0.15.20" }, { name = "unasync" }, ] @@ -1659,7 +1727,8 @@ version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, - { name = "cryptography" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "49.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "httpx" }, { name = "packaging" }, ] @@ -1675,26 +1744,47 @@ wheels = [ name = "pyopenssl" version = "26.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version <= '3.9'", +] dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version > '3.9' and python_full_version < '3.10'", +] +dependencies = [ + { name = "cryptography", version = "49.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, + { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b7/da07bae88f5a9506b4def6f2f4903cf4c3b8831e560dba8fa18ca08f758f/pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341", size = 182024, upload-time = "2026-06-12T20:28:07.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/18/1dd71c9b43192ab83f1d531ad6002dc81108ac36c475f79fb7a295abe2f4/pyopenssl-26.3.0-py3-none-any.whl", hash = "sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3", size = 56008, upload-time = "2026-06-12T20:28:05.999Z" }, +] + [[package]] name = "pyright" -version = "1.1.410" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [[package]] @@ -1702,7 +1792,8 @@ name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, @@ -1745,7 +1836,8 @@ name = "pytest-asyncio" version = "1.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, @@ -1847,27 +1939,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, - { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, - { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, - { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, - { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, - { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -1888,7 +1980,8 @@ version = "24.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "cryptography" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "49.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, { name = "pyasn1" }, { name = "pyasn1-modules" }, ] @@ -2024,7 +2117,8 @@ name = "sphinx" version = "7.4.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -2119,7 +2213,8 @@ version = "2024.10.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.11'" }, @@ -2279,7 +2374,8 @@ name = "starlette" version = "0.49.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "anyio", marker = "python_full_version < '3.10'" }, @@ -2392,7 +2488,8 @@ name = "urllib3" version = "1.26.20" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } wheels = [ @@ -2627,7 +2724,8 @@ name = "winkerberos" version = "0.12.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/2d/75/86d470935167eb1c40d53498993e14cc021d9611a539d61c9b4202c291ab/winkerberos-0.12.2.tar.gz", hash = "sha256:ff91daed04727a0362892802ee093d8da11f08536393526bdf3bc64e04079faa", size = 35672, upload-time = "2025-04-02T14:41:48.274Z" } wheels = [ @@ -2681,7 +2779,8 @@ name = "zope-event" version = "6.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "setuptools", marker = "python_full_version < '3.10'" }, @@ -2709,7 +2808,8 @@ name = "zope-interface" version = "8.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/88/3a/7fcf02178b8fad0a51e67e32765cd039ae505d054d744d76b8c2bbcba5ba/zope_interface-8.0.1.tar.gz", hash = "sha256:eba5610d042c3704a48222f7f7c6ab5b243ed26f917e2bc69379456b115e02d1", size = 253746, upload-time = "2025-09-25T05:55:51.285Z" } wheels = [