From 91b1d17c06c9efae1adeec3fa40197179345cb5d Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 13 Nov 2026 16:26:44 -0500 Subject: [PATCH 1/2] PYTHON-6274 Enable ruff rule PLR2004 --- .evergreen/scripts/mod_wsgi_matrix.py | 4 ++- .evergreen/scripts/setup_tests.py | 4 ++- .evergreen/scripts/test_generate_config.py | 14 +++++++-- bson/__init__.py | 21 ++++++++----- bson/binary.py | 35 +++++++++++++++------- bson/decimal128.py | 6 ++-- bson/json_util.py | 29 ++++++++++++------ bson/objectid.py | 8 +++-- pymongo/_azure_helpers.py | 4 ++- pymongo/_client_bulk_shared.py | 8 +++-- pymongo/_gcp_helpers.py | 4 ++- pymongo/_kms_connect_shared.py | 16 +++++++--- pymongo/asynchronous/auth.py | 5 +++- pymongo/asynchronous/bulk.py | 7 ++--- pymongo/asynchronous/client_bulk.py | 5 ++-- pymongo/asynchronous/collection.py | 13 +++++--- pymongo/asynchronous/encryption.py | 4 ++- pymongo/asynchronous/srv_resolver.py | 11 +++++-- pymongo/bulk_shared.py | 4 +-- pymongo/client_session_shared.py | 6 ++-- pymongo/common.py | 14 +++++++-- pymongo/compression_support.py | 3 +- pymongo/cursor_shared.py | 3 +- pymongo/errors.py | 10 +++++-- pymongo/helpers_shared.py | 17 +++++++---- pymongo/mongo_client_shared.py | 3 +- pymongo/network_layer.py | 22 +++++++++----- pymongo/ocsp_support.py | 4 ++- pymongo/read_preferences.py | 5 +++- pymongo/server_description.py | 3 +- pymongo/synchronous/auth.py | 5 +++- pymongo/synchronous/bulk.py | 7 ++--- pymongo/synchronous/client_bulk.py | 5 ++-- pymongo/synchronous/collection.py | 13 +++++--- pymongo/synchronous/encryption.py | 4 ++- pymongo/synchronous/srv_resolver.py | 11 +++++-- pymongo/topology_description.py | 5 +++- pymongo/uri_parser_shared.py | 3 +- pyproject.toml | 4 ++- tools/compare_import_time.py | 3 +- 40 files changed, 247 insertions(+), 105 deletions(-) diff --git a/.evergreen/scripts/mod_wsgi_matrix.py b/.evergreen/scripts/mod_wsgi_matrix.py index 2b8bb6611b..908baf22f9 100644 --- a/.evergreen/scripts/mod_wsgi_matrix.py +++ b/.evergreen/scripts/mod_wsgi_matrix.py @@ -19,8 +19,10 @@ }, ] +_EXPECTED_ARGC = 3 + if __name__ == "__main__": - if len(sys.argv) == 3 and sys.argv[1] == "--index": + if len(sys.argv) == _EXPECTED_ARGC and sys.argv[1] == "--index": # Emit one version pair in GITHUB_OUTPUT format. entry = VERSIONS[int(sys.argv[2])] print(f"python-version={entry['python-version']}") diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index c12599cbc0..cd8982f869 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -74,6 +74,8 @@ # (MONGOCRYPT-938). LIBMONGOCRYPT_VERSION = "1.21.0" +_HTTP_OK = 200 + def is_set(var: str) -> bool: value = os.environ.get(var, "") @@ -149,7 +151,7 @@ def setup_libmongocrypt(): LOGGER.info(f"Fetching {url}...") with request.urlopen(request.Request(url), timeout=15.0) as response: # noqa: S310 - if response.status == 200: + if response.status == _HTTP_OK: with Path("libmongocrypt.tar.gz").open("wb") as f: f.write(response.read()) Path("libmongocrypt").mkdir() diff --git a/.evergreen/scripts/test_generate_config.py b/.evergreen/scripts/test_generate_config.py index 67f9e8a639..398c971590 100644 --- a/.evergreen/scripts/test_generate_config.py +++ b/.evergreen/scripts/test_generate_config.py @@ -16,6 +16,10 @@ import generate_config import generate_config_utils +_MIN_PR_TASKS = 2 +_MAX_STANDARD_PR_TASKS = 5 +_MAX_NEWER_STANDARD_PR_TASKS = 3 + def all_tasks(): tasks = [] @@ -160,7 +164,7 @@ def test_pr_tasks_cover_version_boundaries(): assert seen == set(pythons), ( f"{version} pr tasks pin {sorted(seen)}, want {sorted(pythons)}" ) - assert len(tasks) >= 2, f"{version} has too few server-version pr tasks" + assert len(tasks) >= _MIN_PR_TASKS, f"{version} has too few server-version pr tasks" def test_server_version_rotated_tasks_keep_coverage(): @@ -203,7 +207,9 @@ def test_standard_family_pr_budget(): filter). """ tasks = [task for task in pr_tasks() if "test-standard" in (task.tags or [])] - assert len(tasks) <= 5, f"too many test-standard PR tasks: {[t.name for t in tasks]}" + assert len(tasks) <= _MAX_STANDARD_PR_TASKS, ( + f"too many test-standard PR tasks: {[t.name for t in tasks]}" + ) newer = [ task for task in tasks @@ -212,7 +218,9 @@ def test_standard_family_pr_budget(): for tag in ("python-3.14", "python-3.14t", "python-3.15", "python-3.15t") ) ] - assert len(newer) <= 3, f"too many 3.14+ test-standard PR tasks: {[t.name for t in newer]}" + assert len(newer) <= _MAX_NEWER_STANDARD_PR_TASKS, ( + f"too many 3.14+ test-standard PR tasks: {[t.name for t in newer]}" + ) def test_pr_tasks_run_coverage(): diff --git a/bson/__init__.py b/bson/__init__.py index 4fc30517aa..460bc0f839 100644 --- a/bson/__init__.py +++ b/bson/__init__.py @@ -98,6 +98,7 @@ ALL_UUID_SUBTYPES, CSHARP_LEGACY, JAVA_LEGACY, + OLD_BINARY_SUBTYPE, OLD_UUID_SUBTYPE, STANDARD, UUID_SUBTYPE, @@ -228,6 +229,12 @@ BSONMIN = b"\xff" # Min key BSONMAX = b"\x7f" # Max key +_INT32_MIN = -(2**31) +_INT32_MAX = 2**31 - 1 +_INT32_SIZE = 4 +# A BSON document is at minimum the 4-byte length prefix and the EOO byte. +_MIN_DOCUMENT_SIZE = 5 + _UNPACK_FLOAT_FROM = struct.Struct(" bytes: def _encode_binary(name: bytes, value: Binary, dummy0: Any, dummy1: Any) -> bytes: """Encode bson.binary.Binary.""" subtype = value.subtype - if subtype == 2: + if subtype == OLD_BINARY_SUBTYPE: value = _PACK_INT(len(value)) + value # type: ignore return b"\x05" + name + _PACK_LENGTH_SUBTYPE(len(value), subtype) + value @@ -861,7 +868,7 @@ def _encode_code(name: bytes, value: Code, dummy: Any, opts: CodecOptions[Any]) def _encode_int(name: bytes, value: int, dummy0: Any, dummy1: Any) -> bytes: """Encode a python int.""" - if -2147483648 <= value <= 2147483647: + if _INT32_MIN <= value <= _INT32_MAX: return b"\x10" + name + _PACK_INT(value) else: try: @@ -1134,10 +1141,10 @@ def _decode_all(data: _ReadableBuffer, opts: CodecOptions[_DocumentType]) -> lis try: while position < data_len: # A document is at minimum 5 bytes: the 4-byte size and the EOO. - if data_len - position < 5: + if data_len - position < _MIN_DOCUMENT_SIZE: raise InvalidBSON("not enough data for a BSON document") obj_size = _UNPACK_INT_FROM(data, position)[0] - if obj_size < 5: + if obj_size < _MIN_DOCUMENT_SIZE: raise InvalidBSON("invalid message size") if data_len - position < obj_size: raise InvalidBSON("objsize too large") @@ -1397,9 +1404,9 @@ def decode_file_iter( size_data: Any = file_obj.read(4) if not size_data: break # Finished with file normally. - elif len(size_data) != 4: + elif len(size_data) != _INT32_SIZE: raise InvalidBSON("cut off in middle of objsize") - obj_size = _UNPACK_INT_FROM(size_data, 0)[0] - 4 + obj_size = _UNPACK_INT_FROM(size_data, 0)[0] - _INT32_SIZE elements = size_data + file_obj.read(max(0, obj_size)) yield _bson_to_dict(elements, opts) # type:ignore[misc] diff --git a/bson/binary.py b/bson/binary.py index 4409fa4b7a..07db17faff 100644 --- a/bson/binary.py +++ b/bson/binary.py @@ -209,6 +209,15 @@ class UuidRepresentation: """BSON binary subtype for any user defined structure. """ +# Binary subtypes are one byte, so the valid range is [0, 256). +_SUBTYPE_LIMIT = 256 +_UUID_LENGTH = 16 +_MAX_PADDING = 7 +_INT8_MIN = -128 +_INT8_MAX = 127 +# Packed-bit vectors are restricted to the signed int8 range when built from numpy arrays. +_PACKED_BIT_MAX = 127 + class BinaryVectorDtype(Enum): """Datatypes of vector subtype. @@ -316,7 +325,7 @@ def __new__( ) -> Self: if not isinstance(subtype, int): raise TypeError(f"subtype must be an instance of int, not {type(subtype)}") - if subtype >= 256 or subtype < 0: + if subtype >= _SUBTYPE_LIMIT or subtype < 0: raise ValueError("subtype must be contained in [0, 256)") # Support any type that implements the buffer protocol. self = bytes.__new__(cls, memoryview(data).tobytes()) @@ -405,8 +414,10 @@ def as_uuid(self, uuid_representation: int = UuidRepresentation.STANDARD) -> UUI if uuid_representation == UuidRepresentation.UNSPECIFIED: raise ValueError("uuid_representation cannot be UNSPECIFIED") - if len(self) != 16: - raise ValueError(f"cannot decode a uuid from a {len(self)}-byte payload, expected 16") + if len(self) != _UUID_LENGTH: + raise ValueError( + f"cannot decode a uuid from a {len(self)}-byte payload, expected {_UUID_LENGTH}" + ) if uuid_representation == UuidRepresentation.PYTHON_LEGACY: if self.subtype == OLD_UUID_SUBTYPE: @@ -496,7 +507,7 @@ def from_vector( raise ValueError(f"padding does not apply to {dtype=}") elif dtype == BinaryVectorDtype.PACKED_BIT: # pack ints in [0, 255] as unsigned uint8 format_str = "B" - if 0 <= padding > 7: + if 0 <= padding > _MAX_PADDING: raise ValueError(f"{padding=}. It must be in [0,1, ..7].") if padding and not vector: raise ValueError("Empty vector with non-zero padding.") @@ -526,12 +537,12 @@ def from_vector( if dtype == BinaryVectorDtype.FLOAT32: vector = vector.astype(np.dtype("float32"), copy=False) elif dtype == BinaryVectorDtype.INT8: - if vector.min() >= -128 and vector.max() <= 127: + if vector.min() >= _INT8_MIN and vector.max() <= _INT8_MAX: vector = vector.astype(np.dtype("int8"), copy=False) else: raise ValueError("Values found outside INT8 range.") elif dtype == BinaryVectorDtype.PACKED_BIT: - if vector.min() >= 0 and vector.max() <= 127: + if vector.min() >= 0 and vector.max() <= _PACKED_BIT_MAX: vector = vector.astype(np.dtype("uint8"), copy=False) else: raise ValueError("Values found outside UINT8 range.") @@ -588,8 +599,10 @@ def as_vector(self, return_numpy: bool = False) -> BinaryVector: # data packed as uint8 if padding and not n_bytes: raise ValueError("Corrupt data. Vector has a padding P, but no data.") - if padding > 7 or padding < 0: - raise ValueError(f"Corrupt data. Padding ({padding}) must be between 0 and 7.") + if padding > _MAX_PADDING or padding < 0: + raise ValueError( + f"Corrupt data. Padding ({padding}) must be between 0 and {_MAX_PADDING}." + ) dtype_format = "B" format_string = f"<{n_bytes}{dtype_format}" unpacked_uint8s = list(struct.unpack_from(format_string, self, offset)) @@ -622,8 +635,10 @@ def as_vector(self, return_numpy: bool = False) -> BinaryVector: # data packed as uint8 if padding and not n_bytes: raise ValueError("Corrupt data. Vector has a padding P, but no data.") - if padding > 7 or padding < 0: - raise ValueError(f"Corrupt data. Padding ({padding}) must be between 0 and 7.") + if padding > _MAX_PADDING or padding < 0: + raise ValueError( + f"Corrupt data. Padding ({padding}) must be between 0 and {_MAX_PADDING}." + ) data = np.frombuffer(self[offset:], dtype="uint8") if padding and np.unpackbits(data[-1])[-padding:].sum() > 0: warnings.warn( diff --git a/bson/decimal128.py b/bson/decimal128.py index 1e62d1767b..4b84f71066 100644 --- a/bson/decimal128.py +++ b/bson/decimal128.py @@ -36,6 +36,7 @@ _EXPONENT_MIN = -6143 _MAX_DIGITS = 34 _MAX_COEFFICIENT = 10**_MAX_DIGITS - 1 +_BYTE_LENGTH = 16 _INF = 0x7800000000000000 _NAN = 0x7C00000000000000 @@ -259,7 +260,8 @@ def __init__(self, value: _VALUE_OPTIONS) -> None: if isinstance(value, (str, decimal.Decimal)): self.__high, self.__low = _decimal_to_128(value) elif isinstance(value, (list, tuple)): - if len(value) != 2: + # A literal length is required for mypy to narrow the tuple type. + if len(value) != 2: # noqa: PLR2004 raise ValueError( "Invalid size for creation of Decimal128 " "from list or tuple. Must have exactly 2 " @@ -325,7 +327,7 @@ def from_bid(cls: type[Decimal128], value: bytes) -> Decimal128: """ if not isinstance(value, bytes): raise TypeError(f"value must be an instance of bytes, not {type(value)}") - if len(value) != 16: + if len(value) != _BYTE_LENGTH: raise ValueError("value must be exactly 16 bytes") return cls((_UNPACK_64(value[8:])[0], _UNPACK_64(value[:8])[0])) # type: ignore diff --git a/bson/json_util.py b/bson/json_util.py index 6f32683b17..35429ded0c 100644 --- a/bson/json_util.py +++ b/bson/json_util.py @@ -140,6 +140,17 @@ from bson.timestamp import Timestamp from bson.tz_util import utc +# Legacy mongoexport subtype values use the top 32 bits for a negative sign. +_MONGOEXPORT_SUBTYPE_MASK = 0xFFFFFF80 +_MAX_SUBTYPE_HEX_LENGTH = 2 +_BINARY_COMPONENT_COUNT = 2 +_REGEX_COMPONENT_COUNT = 2 +_DBREF_COMPONENT_COUNT = 2 +_SINGLE_FIELD_COUNT = 1 +_TZ_OFFSET_HOUR_MIN_SEC_LENGTH = 6 +_TZ_OFFSET_HOUR_MIN_LENGTH = 5 +_TZ_OFFSET_HOUR_LENGTH = 3 + _RE_OPT_TABLE = { "i": re.IGNORECASE, "l": re.LOCALE, @@ -565,7 +576,7 @@ def _parse_legacy_binary(doc: Any, json_options: JSONOptions) -> Binary | uuid.U if isinstance(doc["$type"], int): doc["$type"] = "{:02x}".format(doc["$type"]) subtype = int(doc["$type"], 16) - if subtype >= 0xFFFFFF80: # Handle mongoexport values + if subtype >= _MONGOEXPORT_SUBTYPE_MASK: # Handle mongoexport values subtype = int(doc["$type"][6:], 16) data = base64.b64decode(doc["$binary"].encode()) return _binary_or_uuid(data, subtype, json_options) @@ -577,9 +588,9 @@ def _parse_canonical_binary(doc: Any, json_options: JSONOptions) -> Binary | uui subtype = binary["subType"] if not isinstance(b64, str): raise TypeError(f"$binary base64 must be a string: {doc}") - if not isinstance(subtype, str) or len(subtype) > 2: + if not isinstance(subtype, str) or len(subtype) > _MAX_SUBTYPE_HEX_LENGTH: raise TypeError(f"$binary subType must be a string at most 2 characters: {doc}") - if len(binary) != 2: + if len(binary) != _BINARY_COMPONENT_COUNT: raise TypeError(f'$binary must include only "base64" and "subType" components: {doc}') data = base64.b64decode(b64.encode()) @@ -630,12 +641,12 @@ def _parse_canonical_datetime( ) if offset and offset != "Z": - if len(offset) == 6: + if len(offset) == _TZ_OFFSET_HOUR_MIN_SEC_LENGTH: hours, minutes = offset[1:].split(":") secs = int(hours) * 3600 + int(minutes) * 60 - elif len(offset) == 5: + elif len(offset) == _TZ_OFFSET_HOUR_MIN_LENGTH: secs = int(offset[1:3]) * 3600 + int(offset[3:]) * 60 - elif len(offset) == 3: + elif len(offset) == _TZ_OFFSET_HOUR_LENGTH: secs = int(offset[1:3]) * 3600 if offset[0] == "-": secs *= -1 @@ -681,9 +692,9 @@ def _parse_canonical_code(doc: Any, dummy0: Any) -> Code: def _parse_canonical_regex(doc: Any, dummy0: Any) -> Regex[str]: """Decode a JSON regex to bson.regex.Regex.""" regex = doc["$regularExpression"] - if len(doc) != 1: + if len(doc) != _SINGLE_FIELD_COUNT: raise TypeError(f"Bad $regularExpression, extra field(s): {doc}") - if len(regex) != 2: + if len(regex) != _REGEX_COMPONENT_COUNT: raise TypeError( f'Bad $regularExpression must include only "pattern and "options" components: {doc}' ) @@ -718,7 +729,7 @@ def _parse_canonical_dbpointer(doc: Any, dummy0: Any) -> Any: raise TypeError(f"Bad $dbPointer, extra field $db: {dbref_doc}") if not isinstance(dbref.id, ObjectId): raise TypeError(f"Bad $dbPointer, $id must be an ObjectId: {dbref_doc}") - if len(dbref_doc) != 2: + if len(dbref_doc) != _DBREF_COMPONENT_COUNT: raise TypeError(f"Bad $dbPointer, extra field(s) in DBRef: {dbref_doc}") return dbref else: diff --git a/bson/objectid.py b/bson/objectid.py index d4c9dce545..6c891b7bf9 100644 --- a/bson/objectid.py +++ b/bson/objectid.py @@ -29,6 +29,8 @@ from bson.tz_util import utc _MAX_COUNTER_VALUE = 0xFFFFFF +_OBJECT_ID_BYTE_LENGTH = 12 +_OBJECT_ID_HEX_LENGTH = 24 _PACK_INT = struct.Struct(">I").pack _PACK_INT_RANDOM = struct.Struct(">I5s").pack _UNPACK_INT = struct.Struct(">I").unpack @@ -104,17 +106,17 @@ def __init__(self, oid: str | ObjectId | bytes | None = None) -> None: # 4 bytes current time, 5 bytes random, 3 bytes inc. self.__id = _PACK_INT_RANDOM(int(time.time()), ObjectId._random()) + _PACK_INT(inc)[1:4] - elif isinstance(oid, bytes) and len(oid) == 12: + elif isinstance(oid, bytes) and len(oid) == _OBJECT_ID_BYTE_LENGTH: self.__id = oid elif isinstance(oid, str): - if len(oid) == 24: + if len(oid) == _OBJECT_ID_HEX_LENGTH: try: self.__id = bytes.fromhex(oid) except (TypeError, ValueError): _raise_invalid_id(oid) # bytes.fromhex() skips ASCII whitespace, so a 24-character # string can decode to fewer than 12 bytes. - if len(self.__id) != 12: + if len(self.__id) != _OBJECT_ID_BYTE_LENGTH: _raise_invalid_id(oid) else: _raise_invalid_id(oid) diff --git a/pymongo/_azure_helpers.py b/pymongo/_azure_helpers.py index 6b96e48fbf..d352d5018f 100644 --- a/pymongo/_azure_helpers.py +++ b/pymongo/_azure_helpers.py @@ -20,6 +20,8 @@ from typing import Any from urllib.parse import quote +_HTTP_OK = 200 + def _get_azure_response( resource: str, client_id: str | None = None, timeout: float = 5 @@ -42,7 +44,7 @@ def _get_azure_response( msg = f"Failed to acquire IMDS access token: {e}" raise ValueError(msg) from None - if status != 200: + if status != _HTTP_OK: msg = "Failed to acquire IMDS access token." raise ValueError(msg) try: diff --git a/pymongo/_client_bulk_shared.py b/pymongo/_client_bulk_shared.py index fdb40a25c6..aadd4b4e2f 100644 --- a/pymongo/_client_bulk_shared.py +++ b/pymongo/_client_bulk_shared.py @@ -26,7 +26,11 @@ validate_ok_for_replace, validate_ok_for_update, ) -from pymongo.errors import ClientBulkWriteException, OperationFailure +from pymongo.errors import ( + _MMAPV1_TRANSACTION_NUMBER_CODE, + ClientBulkWriteException, + OperationFailure, +) from pymongo.helpers_shared import _get_wce_doc from pymongo.message import _ClientBulkWriteContext @@ -79,7 +83,7 @@ def _throw_client_bulk_write_exception( err = full_result["writeErrors"][0] code = err["code"] msg = err["errmsg"] - if code == 20 and msg.startswith("Transaction numbers"): + if code == _MMAPV1_TRANSACTION_NUMBER_CODE and msg.startswith("Transaction numbers"): errmsg = ( "This MongoDB deployment does not support " "retryable writes. Please add retryWrites=false " diff --git a/pymongo/_gcp_helpers.py b/pymongo/_gcp_helpers.py index 71fddc36f6..c88e5ce9dc 100644 --- a/pymongo/_gcp_helpers.py +++ b/pymongo/_gcp_helpers.py @@ -18,6 +18,8 @@ from typing import Any +_HTTP_OK = 200 + def _get_gcp_response(resource: str, timeout: float = 5) -> dict[str, Any]: from urllib.request import Request, urlopen @@ -34,7 +36,7 @@ def _get_gcp_response(resource: str, timeout: float = 5) -> dict[str, Any]: msg = f"Failed to acquire IMDS access token: {e}" raise ValueError(msg) from None - if status != 200: + if status != _HTTP_OK: msg = "Failed to acquire IMDS access token." raise ValueError(msg) diff --git a/pymongo/_kms_connect_shared.py b/pymongo/_kms_connect_shared.py index 4620883978..771a10f0a4 100644 --- a/pymongo/_kms_connect_shared.py +++ b/pymongo/_kms_connect_shared.py @@ -86,6 +86,14 @@ def _close_rejected_kms_socket(obj: Any) -> None: # Cap the CONNECT response header so a silent proxy cannot grow the buffer without bound. _MAX_CONNECT_HEADER = 8192 +# HTTP status codes for a successful CONNECT response. +_HTTP_OK = 200 +_HTTP_REDIRECT = 300 +_HTTP_STATUS_CODE_LENGTH = 3 +_MIN_HTTP_STATUS_PARTS = 2 +# The number of directions a bridged socket is read in. +_BRIDGE_DIRECTIONS = 2 + # An RFC 7230 token: the grammar for a header field name. _TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") @@ -285,12 +293,12 @@ def _tunnel(self, sock: socket.socket, context: KMSConnectContext, deadline: flo # "HTTP/1.1 201"; require a three-digit code and reject malformed lines. parts = status.split(b" ", 2) valid = ( - len(parts) >= 2 + len(parts) >= _MIN_HTTP_STATUS_PARTS and parts[0].startswith(b"HTTP/") - and len(parts[1]) == 3 + and len(parts[1]) == _HTTP_STATUS_CODE_LENGTH and parts[1].isdigit() ) - if not valid or not 200 <= int(parts[1]) < 300: + if not valid or not _HTTP_OK <= int(parts[1]) < _HTTP_REDIRECT: raise OSError(f"proxy refused CONNECT to {target}: {status!r}") def _bridge( @@ -329,7 +337,7 @@ def relay(src: socket.socket, dst: socket.socket) -> None: with phases_lock: finished[src].add("read") finished[dst].add("write") - done = [s for s, marks in finished.items() if len(marks) == 2] + done = [s for s, marks in finished.items() if len(marks) == _BRIDGE_DIRECTIONS] for s in done: del finished[s] for sock in done: diff --git a/pymongo/asynchronous/auth.py b/pymongo/asynchronous/auth.py index 64008c7e2f..415e8812ac 100644 --- a/pymongo/asynchronous/auth.py +++ b/pymongo/asynchronous/auth.py @@ -68,6 +68,9 @@ _IS_SYNC = False +# RFC 5802 requires at least 4096 SCRAM iterations. +_MIN_SCRAM_ITERATIONS = 4096 + async def _authenticate_scram( credentials: MongoCredential, conn: AsyncConnection, mechanism: str @@ -102,7 +105,7 @@ async def _authenticate_scram( server_first = res["payload"] parsed = _parse_scram_response(server_first) iterations = int(parsed[b"i"]) - if iterations < 4096: + if iterations < _MIN_SCRAM_ITERATIONS: raise OperationFailure("Server returned an invalid iteration count.") salt = parsed[b"s"] rnonce = parsed[b"r"] diff --git a/pymongo/asynchronous/bulk.py b/pymongo/asynchronous/bulk.py index b30210b8cd..bcf1fe0726 100644 --- a/pymongo/asynchronous/bulk.py +++ b/pymongo/asynchronous/bulk.py @@ -28,9 +28,7 @@ from pymongo import _csot from pymongo._telemetry import _generate_op_id_or_none -from pymongo.asynchronous.command_runner import ( - run_bulk_write_command, -) +from pymongo.asynchronous.command_runner import run_bulk_write_command from pymongo.asynchronous.helpers import _handle_reauth from pymongo.bulk_shared import ( _COMMANDS, @@ -39,6 +37,7 @@ _raise_bulk_write_error, ) from pymongo.client_session_shared import _validate_session_write_concern +from pymongo.common import WIRE_VERSION_8_0 from pymongo.errors import ( ConfigurationError, InvalidOperation, @@ -407,7 +406,7 @@ async def execute_no_results( raise ConfigurationError("arrayFilters is unsupported for unacknowledged writes.") # Guard against unsupported unacknowledged writes. unack = write_concern and not write_concern.acknowledged - if unack and self.uses_sort and conn.max_wire_version < 25: + if unack and self.uses_sort and conn.max_wire_version < WIRE_VERSION_8_0: raise ConfigurationError( "Must be connected to MongoDB 8.0+ to use sort on unacknowledged update commands." ) diff --git a/pymongo/asynchronous/client_bulk.py b/pymongo/asynchronous/client_bulk.py index 7b2b1833ae..e1f1bc3fb9 100644 --- a/pymongo/asynchronous/client_bulk.py +++ b/pymongo/asynchronous/client_bulk.py @@ -35,6 +35,7 @@ ) from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.helpers import _handle_reauth +from pymongo.common import WIRE_VERSION_8_0 if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping @@ -385,7 +386,7 @@ async def retryable_bulk( conn: AsyncConnection, retryable: bool, ) -> None: - if conn.max_wire_version < 25: + if conn.max_wire_version < WIRE_VERSION_8_0: raise InvalidOperation( "MongoClient.bulk_write requires MongoDB server version 8.0+." ) @@ -485,7 +486,7 @@ async def execute( if not self.write_concern.acknowledged: async with await self.client._conn_for_writes(session, operation) as connection: - if connection.max_wire_version < 25: + if connection.max_wire_version < WIRE_VERSION_8_0: raise InvalidOperation( "MongoClient.bulk_write requires MongoDB server version 8.0+." ) diff --git a/pymongo/asynchronous/collection.py b/pymongo/asynchronous/collection.py index 3a8b7f83b8..f0a33473c9 100644 --- a/pymongo/asynchronous/collection.py +++ b/pymongo/asynchronous/collection.py @@ -48,7 +48,12 @@ AsyncRawBatchCursor, ) from pymongo.collation import validate_collation_or_none -from pymongo.common import _ecoc_coll_name, _esc_coll_name +from pymongo.common import ( + WIRE_VERSION_7_0, + WIRE_VERSION_8_0, + _ecoc_coll_name, + _esc_coll_name, +) from pymongo.errors import ( ConfigurationError, InvalidName, @@ -640,7 +645,7 @@ async def _create_helper( async def inner( session: AsyncClientSession | None, conn: AsyncConnection, _retryable_write: bool ) -> None: - if qev2_required and conn.max_wire_version < 21: + if qev2_required and conn.max_wire_version < WIRE_VERSION_7_0: raise ConfigurationError( "Driver support of Queryable Encryption is incompatible with server. " "Upgrade server to use Queryable Encryption. " @@ -1011,7 +1016,7 @@ async def _update( hint = helpers_shared._index_document(hint) update_doc["hint"] = hint if sort is not None: - if not acknowledged and conn.max_wire_version < 25: + if not acknowledged and conn.max_wire_version < WIRE_VERSION_8_0: raise ConfigurationError( "Must be connected to MongoDB 8.0+ to use sort on unacknowledged update commands." ) @@ -2560,7 +2565,7 @@ async def _cmd( except OperationFailure as exc: # Ignore NamespaceNotFound errors to match the behavior # of reading from *.system.indexes. - if exc.code != 26: + if exc.code != helpers_shared._NAMESPACE_NOT_FOUND_CODE: raise cursor = {"id": 0, "firstBatch": []} cmd_cursor = AsyncCommandCursor( diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 113b7b6612..943164609c 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -103,6 +103,8 @@ _HTTPS_PORT = 443 _MONGOCRYPTD_TIMEOUT_MS = 10000 +# BSON binary subtype for an encrypted BSON value. +_ENCRYPTED_SUBTYPE = 6 _DATA_KEY_OPTS: CodecOptions[dict[str, Any]] = CodecOptions( document_class=dict[str, Any], uuid_representation=STANDARD @@ -987,7 +989,7 @@ async def decrypt(self, value: Binary) -> Any: :return: The decrypted BSON value. """ self._check_closed() - if not (isinstance(value, Binary) and value.subtype == 6): + if not (isinstance(value, Binary) and value.subtype == _ENCRYPTED_SUBTYPE): raise TypeError("value to decrypt must be a bson.binary.Binary with subtype 6") with _wrap_encryption_errors(): diff --git a/pymongo/asynchronous/srv_resolver.py b/pymongo/asynchronous/srv_resolver.py index a95f7570b0..f69f161000 100644 --- a/pymongo/asynchronous/srv_resolver.py +++ b/pymongo/asynchronous/srv_resolver.py @@ -32,6 +32,11 @@ _IS_SYNC = False +# A domain name has at least two labels: "example.com". +_MIN_DOMAIN_LABELS = 2 +# An SRV host must have at least three labels: "_mongodb._tcp.example.com". +_MIN_SRV_HOST_LABELS = 3 + # dnspython can return bytes or str from various parts # of its API depending on version. We always want str. @@ -89,7 +94,7 @@ def __init__( suffix = _to_punycode(suffix).lower() is_special_use = suffix in SPECIAL_USE_LABELS - if len(suffix.split(".")) < 2 and not is_special_use: + if len(suffix.split(".")) < _MIN_DOMAIN_LABELS and not is_special_use: raise ConfigurationError( "srvAllowedHostsSuffix must contain at least two '.' separated labels, " f"got: {srv_allowed_hosts_suffix}" @@ -108,7 +113,7 @@ def __init__( pass try: split_fqdn = self.__fqdn.split(".") - self.__plist = split_fqdn[1:] if len(split_fqdn) > 2 else split_fqdn + self.__plist = split_fqdn[1:] if len(split_fqdn) > _MIN_DOMAIN_LABELS else split_fqdn except Exception: raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,)) from None self.__slen = len(self.__plist) @@ -163,7 +168,7 @@ def _validate_host(self, srv_host: str) -> None: if not srv_host.endswith(self.__srv_allowed_hosts_suffix): raise ConfigurationError(f"Invalid SRV host: {srv_host}") else: - if self.__fqdn == srv_host and self.nparts < 3: + if self.__fqdn == srv_host and self.nparts < _MIN_SRV_HOST_LABELS: raise ConfigurationError( "Invalid SRV host: return address is identical to SRV hostname" ) diff --git a/pymongo/bulk_shared.py b/pymongo/bulk_shared.py index 0d54b73977..42dbb0dd17 100644 --- a/pymongo/bulk_shared.py +++ b/pymongo/bulk_shared.py @@ -26,7 +26,7 @@ validate_ok_for_replace, validate_ok_for_update, ) -from pymongo.errors import BulkWriteError, OperationFailure +from pymongo.errors import _MMAPV1_TRANSACTION_NUMBER_CODE, BulkWriteError, OperationFailure from pymongo.helpers_shared import _get_wce_doc from pymongo.message import ( _DELETE, @@ -133,7 +133,7 @@ def _raise_bulk_write_error(full_result: _DocumentOut) -> NoReturn: err = full_result["writeErrors"][0] code = err["code"] msg = err["errmsg"] - if code == 20 and msg.startswith("Transaction numbers"): + if code == _MMAPV1_TRANSACTION_NUMBER_CODE and msg.startswith("Transaction numbers"): errmsg = ( "This MongoDB deployment does not support " "retryable writes. Please add retryWrites=false " diff --git a/pymongo/client_session_shared.py b/pymongo/client_session_shared.py index 6e4ac0378f..96ee2dea37 100644 --- a/pymongo/client_session_shared.py +++ b/pymongo/client_session_shared.py @@ -34,7 +34,9 @@ from bson.int64 import Int64 from bson.timestamp import Timestamp from pymongo import _csot +from pymongo.common import WIRE_VERSION_5_0 from pymongo.errors import ( + _MAX_TIME_MS_EXPIRED_CODE, ConfigurationError, ExecutionTimeout, InvalidOperation, @@ -261,7 +263,7 @@ def _reraise_with_unknown_commit(exc: Any) -> NoReturn: def _max_time_expired_error(exc: PyMongoError) -> bool: """Return true if exc is a MaxTimeMSExpired error.""" - return isinstance(exc, OperationFailure) and exc.code == 50 + return isinstance(exc, OperationFailure) and exc.code == _MAX_TIME_MS_EXPIRED_CODE # From the transactions spec, all the retryable writes errors plus @@ -709,7 +711,7 @@ def _update_read_concern( if self.options.causal_consistency and self.operation_time is not None: cmd.setdefault("readConcern", {})["afterClusterTime"] = self.operation_time if self.options.snapshot: - if conn.max_wire_version < 13: + if conn.max_wire_version < WIRE_VERSION_5_0: raise ConfigurationError("Snapshot reads require MongoDB 5.0 or later") rc = cmd.setdefault("readConcern", {}) rc["level"] = "snapshot" diff --git a/pymongo/common.py b/pymongo/common.py index 81e3fa6d9b..8bc900ccd6 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -70,6 +70,16 @@ # MongoDB 7.1, the first release whose mongos continues an exhaust getMore stream. MONGOS_EXHAUST_WIRE_VERSION = 22 +# Wire versions for server feature gates. +WIRE_VERSION_3_6 = 6 +WIRE_VERSION_5_0 = 13 +WIRE_VERSION_6_0 = 17 +WIRE_VERSION_7_0 = 21 +WIRE_VERSION_8_0 = 25 + +_ONE_BILLION = 1e9 +_MAX_APP_NAME_LENGTH = 128 + # Frequency to call hello on servers, in seconds. HEARTBEAT_FREQUENCY = 10 @@ -287,7 +297,7 @@ def validate_positive_float(option: str, value: Any) -> float: # float('inf') doesn't work in 2.4 or 2.5 on Windows, so just cap floats at # one billion - this is a reasonable approximation for infinity - if not 0 < value < 1e9: + if not 0 < value < _ONE_BILLION: raise ValueError(f"{option} must be greater than 0 and less than one billion") return value @@ -584,7 +594,7 @@ def validate_appname_or_none(option: str, value: Any) -> str | None: return value validate_string(option, value) # We need length in bytes, so encode utf8 first. - if len(value.encode("utf-8")) > 128: + if len(value.encode("utf-8")) > _MAX_APP_NAME_LENGTH: raise ValueError(f"{option} must be <= 128 bytes") return value diff --git a/pymongo/compression_support.py b/pymongo/compression_support.py index 84ef43e6f2..4e61f4d9ec 100644 --- a/pymongo/compression_support.py +++ b/pymongo/compression_support.py @@ -25,6 +25,7 @@ from collections.abc import Iterable _SUPPORTED_COMPRESSORS = {"snappy", "zlib", "zstd"} +_MAX_ZLIB_COMPRESSION_LEVEL = 9 _NO_COMPRESSION = {HelloCompat.CMD, HelloCompat.LEGACY_CMD} _NO_COMPRESSION.update(_SENSITIVE_COMMANDS) @@ -107,7 +108,7 @@ def validate_zlib_compression_level(option: str, value: Any) -> int: level = int(value) except Exception: raise TypeError(f"{option} must be an integer, not {value!r}") from None - if level < -1 or level > 9: + if level < -1 or level > _MAX_ZLIB_COMPRESSION_LEVEL: raise ValueError(f"{option} must be between -1 and 9, not {level}.") return level diff --git a/pymongo/cursor_shared.py b/pymongo/cursor_shared.py index 6fdf784750..9a5837ab64 100644 --- a/pymongo/cursor_shared.py +++ b/pymongo/cursor_shared.py @@ -61,7 +61,8 @@ def _split_message( :param message: (request_id, data, max_doc_size) or (request_id, data) """ - if len(message) == 3: + # A literal length is required for mypy to narrow the tuple type. + if len(message) == 3: # noqa: PLR2004 return message # get_more and kill_cursors messages don't include BSON documents. request_id, data = message diff --git a/pymongo/errors.py b/pymongo/errors.py index 70d436923c..87f7dea42c 100644 --- a/pymongo/errors.py +++ b/pymongo/errors.py @@ -30,6 +30,10 @@ from pymongo.results import ClientBulkWriteResult from pymongo.typings import _DocumentOut +# Server error codes. +_MAX_TIME_MS_EXPIRED_CODE = 50 +_MMAPV1_TRANSACTION_NUMBER_CODE = 20 + class PyMongoError(Exception): """Base class for all PyMongo exceptions.""" @@ -286,7 +290,9 @@ class DuplicateKeyError(WriteError): def _wtimeout_error(error: Any) -> bool: """Return True if this writeConcernError doc is a caused by a timeout.""" - return error.get("code") == 50 or ("errInfo" in error and error["errInfo"].get("wtimeout")) + return error.get("code") == _MAX_TIME_MS_EXPIRED_CODE or ( + "errInfo" in error and error["errInfo"].get("wtimeout") + ) class BulkWriteError(OperationFailure): @@ -312,7 +318,7 @@ def timeout(self) -> bool: return True werrs = self.details.get("writeErrors", []) - return bool(werrs and werrs[-1].get("code") == 50) + return bool(werrs and werrs[-1].get("code") == _MAX_TIME_MS_EXPIRED_CODE) class ClientBulkWriteException(OperationFailure): diff --git a/pymongo/helpers_shared.py b/pymongo/helpers_shared.py index 594f55fb26..9d616fdd2c 100644 --- a/pymongo/helpers_shared.py +++ b/pymongo/helpers_shared.py @@ -29,6 +29,7 @@ from pymongo import ASCENDING from pymongo.errors import ( + _MAX_TIME_MS_EXPIRED_CODE, CursorNotFound, DuplicateKeyError, ExecutionTimeout, @@ -86,6 +87,12 @@ ] ) +# Server code raised for findAndModify with upsert and other duplicate key errors. +_DUPLICATE_KEY_CODE: int = 11000 +_DUPLICATE_KEY_CODES: frozenset[int] = frozenset([11000, 11001, 12582]) +_CURSOR_NOT_FOUND_CODE: int = 43 +_NAMESPACE_NOT_FOUND_CODE: int = 26 + # Server code raised when re-authentication is required _REAUTHENTICATION_REQUIRED_CODE: int = 391 @@ -312,15 +319,15 @@ def _check_command_response( # Other errors # findAndModify with upsert can raise duplicate key error - if code in (11000, 11001, 12582): + if code in _DUPLICATE_KEY_CODES: raise DuplicateKeyError(errmsg, code, response, max_wire_version) - elif code == 50: + elif code == _MAX_TIME_MS_EXPIRED_CODE: # Append timeout details to MaxTimeMSExpired responses. if pool_opts: timeout_details = _get_timeout_details(pool_opts) errmsg += format_timeout_details(timeout_details) raise ExecutionTimeout(errmsg, code, response, max_wire_version) - elif code == 43: + elif code == _CURSOR_NOT_FOUND_CODE: raise CursorNotFound(errmsg, code, response, max_wire_version) raise OperationFailure(errmsg, code, response, max_wire_version) @@ -330,8 +337,8 @@ def _raise_last_write_error(write_errors: list[Any]) -> NoReturn: # If the last batch had multiple errors only report # the last error to emulate continue_on_error. error = write_errors[-1] - if error.get("code") == 11000: - raise DuplicateKeyError(error.get("errmsg"), 11000, error) + if error.get("code") == _DUPLICATE_KEY_CODE: + raise DuplicateKeyError(error.get("errmsg"), _DUPLICATE_KEY_CODE, error) raise WriteError(error.get("errmsg"), error.get("code"), error) diff --git a/pymongo/mongo_client_shared.py b/pymongo/mongo_client_shared.py index f0578f2de0..f7ddd06017 100644 --- a/pymongo/mongo_client_shared.py +++ b/pymongo/mongo_client_shared.py @@ -22,6 +22,7 @@ from pymongo import _csot from pymongo._telemetry import _generate_op_id_or_none, log_command_retry from pymongo.errors import ( + _MMAPV1_TRANSACTION_NUMBER_CODE, BulkWriteError, ClientBulkWriteException, ConnectionFailure, @@ -71,7 +72,7 @@ def _add_retryable_write_error(exc: PyMongoError) -> None: if doc: code = doc.get("code", 0) # retryWrites on MMAPv1 should raise an actionable error. - if code == 20 and str(exc).startswith("Transaction numbers"): + if code == _MMAPV1_TRANSACTION_NUMBER_CODE and str(exc).startswith("Transaction numbers"): errmsg = ( "This MongoDB deployment does not support " "retryable writes. Please add retryWrites=false " diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index f60e27738c..2f03365244 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -56,6 +56,12 @@ BLOCKING_IO_WRITE_ERROR, ) +# MongoDB wire protocol header sizes. +_MESSAGE_HEADER_SIZE = 16 +_COMPRESSION_HEADER_SIZE = 9 +_OP_COMPRESSED = 2012 +_OP_COMPRESSED_MESSAGE_SIZE = _MESSAGE_HEADER_SIZE + _COMPRESSION_HEADER_SIZE + if TYPE_CHECKING: import socket from collections.abc import Callable @@ -555,7 +561,7 @@ def buffer_updated(self, nbytes: int) -> None: return if self._expecting_header: self._header_index += nbytes - if self._header_index >= 16: + if self._header_index >= _MESSAGE_HEADER_SIZE: self._expecting_header = False try: ( @@ -571,7 +577,7 @@ def buffer_updated(self, nbytes: int) -> None: return if self._expecting_compression: self._compression_index += nbytes - if self._compression_index >= 9: + if self._compression_index >= _COMPRESSION_HEADER_SIZE: self._expecting_compression = False ( self._op_code, @@ -621,14 +627,14 @@ def process_header(self) -> tuple[int, int, int, bool]: """Unpack a MongoDB Wire Protocol header.""" length, _, response_to, op_code = _UNPACK_HEADER(self._header) expecting_compression = False - if op_code == 2012: # OP_COMPRESSED - if length <= 25: + if op_code == _OP_COMPRESSED: # OP_COMPRESSED + if length <= _OP_COMPRESSED_MESSAGE_SIZE: raise ProtocolError( f"Message length ({length!r}) not longer than standard OP_COMPRESSED message header size (25)" ) expecting_compression = True length -= 9 - if length <= 16: + if length <= _MESSAGE_HEADER_SIZE: raise ProtocolError( f"Message length ({length!r}) not longer than standard message header size (16)" ) @@ -748,7 +754,7 @@ def receive_message( if request_id is not None: if request_id != response_to: raise ProtocolError(f"Got response id {response_to!r} but expected {request_id!r}") - if length <= 16: + if length <= _MESSAGE_HEADER_SIZE: raise ProtocolError( f"Message length ({length!r}) not longer than standard message header size (16)" ) @@ -758,8 +764,8 @@ def receive_message( f"message size ({max_message_size!r})" ) data: memoryview | bytes - if op_code == 2012: - if length <= 25: + if op_code == _OP_COMPRESSED: + if length <= _OP_COMPRESSED_MESSAGE_SIZE: raise ProtocolError( f"Message length ({length!r}) not longer than standard OP_COMPRESSED message header size (25)" ) diff --git a/pymongo/ocsp_support.py b/pymongo/ocsp_support.py index 9c7f3ecab8..f99c2d2f7d 100644 --- a/pymongo/ocsp_support.py +++ b/pymongo/ocsp_support.py @@ -115,6 +115,8 @@ _LOGGER = _logging.getLogger(__name__) +_HTTP_OK = 200 + _CERT_REGEX = _re.compile( b"-----BEGIN CERTIFICATE[^\r\n]+.+?-----END CERTIFICATE[^\r\n]+", _re.DOTALL ) @@ -327,7 +329,7 @@ def _get_ocsp_response( except _RequestException as exc: _LOGGER.debug("HTTP request failed: %s", exc) return None - if response.status_code != 200: + if response.status_code != _HTTP_OK: _LOGGER.debug("HTTP request returned %d", response.status_code) return None ocsp_response = _load_der_ocsp_response(response.content) diff --git a/pymongo/read_preferences.py b/pymongo/read_preferences.py index 721725563e..14232a59d6 100644 --- a/pymongo/read_preferences.py +++ b/pymongo/read_preferences.py @@ -42,6 +42,9 @@ _SECONDARY_PREFERRED = 3 _NEAREST = 4 +# MongoDB 5.0, the first server that supports agg $out/$merge with secondary read preference. +_MIN_AGG_WRITE_WIRE_VERSION = 13 + _MONGOS_MODES = ( "primary", @@ -499,7 +502,7 @@ def selection_hook(self, topology_description: TopologyDescription) -> None: if ( topology_description.has_readable_server(ReadPreference.PRIMARY_PREFERRED) and common_wv - and common_wv < 13 + and common_wv < _MIN_AGG_WRITE_WIRE_VERSION ): self.effective_pref = ReadPreference.PRIMARY else: diff --git a/pymongo/server_description.py b/pymongo/server_description.py index df8665d9e0..c2f99834f4 100644 --- a/pymongo/server_description.py +++ b/pymongo/server_description.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any from bson import EPOCH_NAIVE +from pymongo.common import WIRE_VERSION_3_6 from pymongo.hello import Hello from pymongo.server_type import SERVER_TYPE @@ -259,7 +260,7 @@ def retryable_writes_supported(self) -> bool: @property def retryable_reads_supported(self) -> bool: """Checks if this server supports retryable writes.""" - return self._max_wire_version >= 6 + return self._max_wire_version >= WIRE_VERSION_3_6 @property def topology_version(self) -> Mapping[str, Any] | None: diff --git a/pymongo/synchronous/auth.py b/pymongo/synchronous/auth.py index 1fc037c47b..d2c52bceeb 100644 --- a/pymongo/synchronous/auth.py +++ b/pymongo/synchronous/auth.py @@ -68,6 +68,9 @@ _IS_SYNC = True +# RFC 5802 requires at least 4096 SCRAM iterations. +_MIN_SCRAM_ITERATIONS = 4096 + def _authenticate_scram(credentials: MongoCredential, conn: Connection, mechanism: str) -> None: """Authenticate using SCRAM.""" @@ -100,7 +103,7 @@ def _authenticate_scram(credentials: MongoCredential, conn: Connection, mechanis server_first = res["payload"] parsed = _parse_scram_response(server_first) iterations = int(parsed[b"i"]) - if iterations < 4096: + if iterations < _MIN_SCRAM_ITERATIONS: raise OperationFailure("Server returned an invalid iteration count.") salt = parsed[b"s"] rnonce = parsed[b"r"] diff --git a/pymongo/synchronous/bulk.py b/pymongo/synchronous/bulk.py index b3b54abbe7..4196d777f3 100644 --- a/pymongo/synchronous/bulk.py +++ b/pymongo/synchronous/bulk.py @@ -35,6 +35,7 @@ _raise_bulk_write_error, ) from pymongo.client_session_shared import _validate_session_write_concern +from pymongo.common import WIRE_VERSION_8_0 from pymongo.errors import ( ConfigurationError, InvalidOperation, @@ -48,9 +49,7 @@ _EncryptedBulkWriteContext, ) from pymongo.read_preferences import ReadPreference -from pymongo.synchronous.command_runner import ( - run_bulk_write_command, -) +from pymongo.synchronous.command_runner import run_bulk_write_command from pymongo.synchronous.helpers import _handle_reauth from pymongo.write_concern import WriteConcern @@ -405,7 +404,7 @@ def execute_no_results( raise ConfigurationError("arrayFilters is unsupported for unacknowledged writes.") # Guard against unsupported unacknowledged writes. unack = write_concern and not write_concern.acknowledged - if unack and self.uses_sort and conn.max_wire_version < 25: + if unack and self.uses_sort and conn.max_wire_version < WIRE_VERSION_8_0: raise ConfigurationError( "Must be connected to MongoDB 8.0+ to use sort on unacknowledged update commands." ) diff --git a/pymongo/synchronous/client_bulk.py b/pymongo/synchronous/client_bulk.py index 097df34f89..2a52c7e242 100644 --- a/pymongo/synchronous/client_bulk.py +++ b/pymongo/synchronous/client_bulk.py @@ -28,6 +28,7 @@ from pymongo import _csot from pymongo._telemetry import _generate_op_id_or_none +from pymongo.common import WIRE_VERSION_8_0 from pymongo.synchronous.collection import Collection from pymongo.synchronous.command_cursor import CommandCursor from pymongo.synchronous.command_runner import ( @@ -383,7 +384,7 @@ def retryable_bulk( conn: Connection, retryable: bool, ) -> None: - if conn.max_wire_version < 25: + if conn.max_wire_version < WIRE_VERSION_8_0: raise InvalidOperation( "MongoClient.bulk_write requires MongoDB server version 8.0+." ) @@ -483,7 +484,7 @@ def execute( if not self.write_concern.acknowledged: with self.client._conn_for_writes(session, operation) as connection: - if connection.max_wire_version < 25: + if connection.max_wire_version < WIRE_VERSION_8_0: raise InvalidOperation( "MongoClient.bulk_write requires MongoDB server version 8.0+." ) diff --git a/pymongo/synchronous/collection.py b/pymongo/synchronous/collection.py index 34b00cec50..5ca2977654 100644 --- a/pymongo/synchronous/collection.py +++ b/pymongo/synchronous/collection.py @@ -34,7 +34,12 @@ from bson.son import SON from pymongo import ASCENDING, _csot, common, helpers_shared, message from pymongo.collation import validate_collation_or_none -from pymongo.common import _ecoc_coll_name, _esc_coll_name +from pymongo.common import ( + WIRE_VERSION_7_0, + WIRE_VERSION_8_0, + _ecoc_coll_name, + _esc_coll_name, +) from pymongo.errors import ( ConfigurationError, InvalidName, @@ -639,7 +644,7 @@ def _create_helper( cmd.update(options) def inner(session: ClientSession | None, conn: Connection, _retryable_write: bool) -> None: - if qev2_required and conn.max_wire_version < 21: + if qev2_required and conn.max_wire_version < WIRE_VERSION_7_0: raise ConfigurationError( "Driver support of Queryable Encryption is incompatible with server. " "Upgrade server to use Queryable Encryption. " @@ -1008,7 +1013,7 @@ def _update( hint = helpers_shared._index_document(hint) update_doc["hint"] = hint if sort is not None: - if not acknowledged and conn.max_wire_version < 25: + if not acknowledged and conn.max_wire_version < WIRE_VERSION_8_0: raise ConfigurationError( "Must be connected to MongoDB 8.0+ to use sort on unacknowledged update commands." ) @@ -2551,7 +2556,7 @@ def _cmd( except OperationFailure as exc: # Ignore NamespaceNotFound errors to match the behavior # of reading from *.system.indexes. - if exc.code != 26: + if exc.code != helpers_shared._NAMESPACE_NOT_FOUND_CODE: raise cursor = {"id": 0, "firstBatch": []} cmd_cursor = CommandCursor( diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 774e3347c2..fbc06b3ae3 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -102,6 +102,8 @@ _HTTPS_PORT = 443 _MONGOCRYPTD_TIMEOUT_MS = 10000 +# BSON binary subtype for an encrypted BSON value. +_ENCRYPTED_SUBTYPE = 6 _DATA_KEY_OPTS: CodecOptions[dict[str, Any]] = CodecOptions( document_class=dict[str, Any], uuid_representation=STANDARD @@ -980,7 +982,7 @@ def decrypt(self, value: Binary) -> Any: :return: The decrypted BSON value. """ self._check_closed() - if not (isinstance(value, Binary) and value.subtype == 6): + if not (isinstance(value, Binary) and value.subtype == _ENCRYPTED_SUBTYPE): raise TypeError("value to decrypt must be a bson.binary.Binary with subtype 6") with _wrap_encryption_errors(): diff --git a/pymongo/synchronous/srv_resolver.py b/pymongo/synchronous/srv_resolver.py index 774c36bb48..b4013ffe7b 100644 --- a/pymongo/synchronous/srv_resolver.py +++ b/pymongo/synchronous/srv_resolver.py @@ -32,6 +32,11 @@ _IS_SYNC = True +# A domain name has at least two labels: "example.com". +_MIN_DOMAIN_LABELS = 2 +# An SRV host must have at least three labels: "_mongodb._tcp.example.com". +_MIN_SRV_HOST_LABELS = 3 + # dnspython can return bytes or str from various parts # of its API depending on version. We always want str. @@ -89,7 +94,7 @@ def __init__( suffix = _to_punycode(suffix).lower() is_special_use = suffix in SPECIAL_USE_LABELS - if len(suffix.split(".")) < 2 and not is_special_use: + if len(suffix.split(".")) < _MIN_DOMAIN_LABELS and not is_special_use: raise ConfigurationError( "srvAllowedHostsSuffix must contain at least two '.' separated labels, " f"got: {srv_allowed_hosts_suffix}" @@ -108,7 +113,7 @@ def __init__( pass try: split_fqdn = self.__fqdn.split(".") - self.__plist = split_fqdn[1:] if len(split_fqdn) > 2 else split_fqdn + self.__plist = split_fqdn[1:] if len(split_fqdn) > _MIN_DOMAIN_LABELS else split_fqdn except Exception: raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,)) from None self.__slen = len(self.__plist) @@ -163,7 +168,7 @@ def _validate_host(self, srv_host: str) -> None: if not srv_host.endswith(self.__srv_allowed_hosts_suffix): raise ConfigurationError(f"Invalid SRV host: {srv_host}") else: - if self.__fqdn == srv_host and self.nparts < 3: + if self.__fqdn == srv_host and self.nparts < _MIN_SRV_HOST_LABELS: raise ConfigurationError( "Invalid SRV host: return address is identical to SRV hostname" ) diff --git a/pymongo/topology_description.py b/pymongo/topology_description.py index af4619456b..857b072992 100644 --- a/pymongo/topology_description.py +++ b/pymongo/topology_description.py @@ -583,7 +583,10 @@ def _update_rs_from_primary( sds.pop(server_description.address) return _check_has_primary(sds), replica_set_name, max_set_version, max_election_id - if server_description.max_wire_version is None or server_description.max_wire_version < 17: + if ( + server_description.max_wire_version is None + or server_description.max_wire_version < common.WIRE_VERSION_6_0 + ): new_election_tuple: tuple = (server_description.set_version, server_description.election_id) # type: ignore[type-arg] max_election_tuple: tuple = (max_set_version, max_election_id) # type: ignore[type-arg] if None not in new_election_tuple: diff --git a/pymongo/uri_parser_shared.py b/pymongo/uri_parser_shared.py index f4af8c20fc..2724149f10 100644 --- a/pymongo/uri_parser_shared.py +++ b/pymongo/uri_parser_shared.py @@ -51,6 +51,7 @@ SRV_SCHEME = "mongodb+srv://" SRV_SCHEME_LEN = len(SRV_SCHEME) DEFAULT_PORT = 27017 +_MAX_PORT = 65535 URI_OPTIONS = frozenset( [ @@ -215,7 +216,7 @@ def parse_host(entity: str, default_port: int | None = DEFAULT_PORT) -> _Address "Port contains non-digit characters. Hint: username and password must be escaped according to " "RFC 3986, use urllib.parse.quote_plus" ) - if int(port) > 65535 or int(port) <= 0: + if int(port) > _MAX_PORT or int(port) <= 0: raise ValueError("Port must be an integer between 0 and 65535") port = int(port) diff --git a/pyproject.toml b/pyproject.toml index 3aaf4e7465..6001b31026 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -220,6 +220,7 @@ extend-select = [ "ASYNC", # flake8-async "TC", # flake8-type-checking "PERF", # perflint + "PLR2004", # pylint: magic value used in comparison "D202", # pydocstyle: no blank lines between docstring and function body. "D209", # pydocstyle: closing quotes go on their own line. "D301", # pydocstyle: docstrings containing \ are defined as string literals. @@ -270,7 +271,8 @@ pydocstyle.convention = "pep257" ".evergreen/scripts/setup_uv.py" = ["I002"] "test/*.py" = ["PT", "E402", "PLW", "SIM", "E741", "PTH", "S", "B904", "E722", "T201", "RET", "ARG", "F405", "B028", "PGH001", "B018", "F403", "RUF015", "E731", "B007", - "UP031", "F401", "B023", "F811", "DTZ002", "LOG"] + "UP031", "F401", "B023", "F811", "DTZ002", "LOG", "PLR2004"] +"test/**/*.py" = ["PLR2004"] "tools/*.py" = ["T201"] "tools/test_*.py" = ["E402", "S"] "fuzz/*.py" = ["T201", "E402"] diff --git a/tools/compare_import_time.py b/tools/compare_import_time.py index fdc344f2e9..9f6494998d 100644 --- a/tools/compare_import_time.py +++ b/tools/compare_import_time.py @@ -29,8 +29,9 @@ def get_total_time(sha: str) -> int: curr_time = get_total_time(head_sha) # Check if we got 20% or more slower. +_MAX_REGRESSION_PERCENT = 20 change = int((curr_time - base_time) / base_time * 100) -if change > 20: +if change > _MAX_REGRESSION_PERCENT: print(f"PyMongo import got {change} percent worse") sys.exit(1) From 8ee04dcd7484918b84d3e937f8eb59fd22c49288 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 13 Nov 2026 17:12:07 -0500 Subject: [PATCH 2/2] PYTHON-6274 Rename timezone offset constants by syntax --- bson/json_util.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bson/json_util.py b/bson/json_util.py index 35429ded0c..069ea19195 100644 --- a/bson/json_util.py +++ b/bson/json_util.py @@ -147,9 +147,9 @@ _REGEX_COMPONENT_COUNT = 2 _DBREF_COMPONENT_COUNT = 2 _SINGLE_FIELD_COUNT = 1 -_TZ_OFFSET_HOUR_MIN_SEC_LENGTH = 6 -_TZ_OFFSET_HOUR_MIN_LENGTH = 5 -_TZ_OFFSET_HOUR_LENGTH = 3 +_TZ_OFFSET_HH_COLON_MM_LENGTH = 6 +_TZ_OFFSET_HHMM_LENGTH = 5 +_TZ_OFFSET_HH_LENGTH = 3 _RE_OPT_TABLE = { "i": re.IGNORECASE, @@ -641,12 +641,12 @@ def _parse_canonical_datetime( ) if offset and offset != "Z": - if len(offset) == _TZ_OFFSET_HOUR_MIN_SEC_LENGTH: + if len(offset) == _TZ_OFFSET_HH_COLON_MM_LENGTH: hours, minutes = offset[1:].split(":") secs = int(hours) * 3600 + int(minutes) * 60 - elif len(offset) == _TZ_OFFSET_HOUR_MIN_LENGTH: + elif len(offset) == _TZ_OFFSET_HHMM_LENGTH: secs = int(offset[1:3]) * 3600 + int(offset[3:]) * 60 - elif len(offset) == _TZ_OFFSET_HOUR_LENGTH: + elif len(offset) == _TZ_OFFSET_HH_LENGTH: secs = int(offset[1:3]) * 3600 if offset[0] == "-": secs *= -1