diff --git a/pyproject.toml b/pyproject.toml index 3e28ea7b..01fb0337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ Homepage = "https://github.com/gijzelaerr/python-snap7" Documentation = "https://python-snap7.readthedocs.io/en/latest/" [project.optional-dependencies] -test = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-html", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"] +test = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-html", "hypothesis", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"] cli = ["rich", "click" ] doc = ["sphinx", "sphinx_rtd_theme"] @@ -52,6 +52,7 @@ markers =[ "client", "common", "e2e: end-to-end tests requiring a real PLC connection", + "hypothesis: property-based tests using Hypothesis", "logo", "mainloop", "partner", diff --git a/snap7/util/getters.py b/snap7/util/getters.py index 01c2f963..f17c9759 100644 --- a/snap7/util/getters.py +++ b/snap7/util/getters.py @@ -660,6 +660,29 @@ def get_ldt(bytearray_: Buffer, byte_index: int) -> datetime: def get_dtl(bytearray_: Buffer, byte_index: int) -> datetime: + """Get DTL (Date and Time Long) value from bytearray. + + Notes: + Datatype ``DTL`` consists of 12 bytes in the PLC: + - Bytes 0-1: Year (uint16, big-endian) + - Byte 2: Month (1-12) + - Byte 3: Day (1-31) + - Byte 4: Weekday (1=Sunday, 7=Saturday) + - Byte 5: Hour (0-23) + - Byte 6: Minute (0-59) + - Byte 7: Second (0-59) + - Bytes 8-11: Nanoseconds (uint32, big-endian) + + Args: + bytearray_: buffer to read from. + byte_index: byte index from where to start reading. + + Returns: + datetime value (microsecond precision; sub-microsecond nanoseconds are truncated). + """ + nanoseconds = struct.unpack(">I", bytearray_[byte_index + 8 : byte_index + 12])[0] + microsecond = nanoseconds // 1000 + time_to_datetime = datetime( year=int.from_bytes(bytearray_[byte_index : byte_index + 2], byteorder="big"), month=int(bytearray_[byte_index + 2]), @@ -667,8 +690,8 @@ def get_dtl(bytearray_: Buffer, byte_index: int) -> datetime: hour=int(bytearray_[byte_index + 5]), minute=int(bytearray_[byte_index + 6]), second=int(bytearray_[byte_index + 7]), - microsecond=int(bytearray_[byte_index + 8]), - ) # --- ? noch nicht genau genug + microsecond=microsecond, + ) if time_to_datetime > datetime(2554, 12, 31, 23, 59, 59): raise ValueError("date_val is higher than specification allows.") return time_to_datetime diff --git a/snap7/util/setters.py b/snap7/util/setters.py index 31d6d174..29aab92d 100644 --- a/snap7/util/setters.py +++ b/snap7/util/setters.py @@ -534,7 +534,7 @@ def set_date(bytearray_: Buffer, byte_index: int, date_: date) -> Buffer: elif date_ > date(2168, 12, 31): raise ValueError("date is higher than specification allows.") _days = (date_ - date(1990, 1, 1)).days - bytearray_[byte_index : byte_index + 2] = struct.pack(">h", _days) + bytearray_[byte_index : byte_index + 2] = struct.pack(">H", _days) return bytearray_ @@ -595,6 +595,12 @@ def set_wstring(bytearray_: Buffer, byte_index: int, value: str, max_size: int = if max_size > 16382: raise ValueError(f"max_size: {max_size} > max. allowed 16382 chars") + if any(ord(c) > 0xFFFF for c in value): + raise ValueError( + "Value contains characters outside the Basic Multilingual Plane (codepoint > U+FFFF), " + "which are not supported by the PLC WSTRING type." + ) + size = len(value) if size > max_size: raise ValueError(f"size {size} > max_size {max_size}") @@ -632,7 +638,7 @@ def set_tod(bytearray_: Buffer, byte_index: int, tod: timedelta) -> Buffer: """ if tod.days >= 1 or tod < timedelta(0): raise ValueError("TIME_OF_DAY must be between 00:00:00.000 and 23:59:59.999") - ms = int(tod.total_seconds() * 1000) + ms = (tod.days * 86400 + tod.seconds) * 1000 + tod.microseconds // 1000 bytearray_[byte_index : byte_index + 4] = ms.to_bytes(4, byteorder="big") return bytearray_ diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py new file mode 100644 index 00000000..4f4422d5 --- /dev/null +++ b/tests/test_hypothesis.py @@ -0,0 +1,570 @@ +"""Property-based tests using Hypothesis. + +Tests roundtrip properties for getter/setter pairs, protocol encoding/decoding, +and fuzz tests for robustness against malformed input. +""" + +import math +import struct +from datetime import date, datetime, timedelta + +import pytest +from hypothesis import given, assume, settings, HealthCheck +from hypothesis import strategies as st + +from snap7.util.getters import ( + get_bool, + get_byte, + get_char, + get_date, + get_date_time_object, + get_dint, + get_dword, + get_dtl, + get_fstring, + get_int, + get_lint, + get_lreal, + get_lword, + get_real, + get_sint, + get_string, + get_tod, + get_udint, + get_uint, + get_ulint, + get_usint, + get_wchar, + get_wstring, +) +from snap7.util.setters import ( + set_bool, + set_byte, + set_char, + set_date, + set_dint, + set_dt, + set_dtl, + set_dword, + set_fstring, + set_int, + set_lreal, + set_lword, + set_real, + set_sint, + set_string, + set_tod, + set_udint, + set_uint, + set_usint, + set_wchar, + set_wstring, +) +from snap7.datatypes import S7Area, S7DataTypes, S7WordLen +from snap7.s7protocol import S7Protocol + +pytestmark = pytest.mark.hypothesis + + +# --------------------------------------------------------------------------- +# Getter/Setter roundtrip tests — integer types +# --------------------------------------------------------------------------- + + +@given(st.booleans()) +def test_bool_roundtrip(value: bool) -> None: + for bit_index in range(8): + data = bytearray(1) + set_bool(data, 0, bit_index, value) + assert get_bool(data, 0, bit_index) == value + + +@given(st.integers(min_value=0, max_value=7), st.booleans()) +def test_bool_roundtrip_any_bit(bit_index: int, value: bool) -> None: + data = bytearray(1) + set_bool(data, 0, bit_index, value) + assert get_bool(data, 0, bit_index) == value + + +@given(st.integers(min_value=0, max_value=255)) +def test_byte_roundtrip(value: int) -> None: + data = bytearray(1) + set_byte(data, 0, value) + # get_byte returns the value as an int (despite the bytes type annotation) + assert get_byte(data, 0) == value # type: ignore[comparison-overlap] + + +@given(st.integers(min_value=0, max_value=255)) +def test_usint_roundtrip(value: int) -> None: + data = bytearray(1) + set_usint(data, 0, value) + assert get_usint(data, 0) == value + + +@given(st.integers(min_value=-128, max_value=127)) +def test_sint_roundtrip(value: int) -> None: + data = bytearray(1) + set_sint(data, 0, value) + assert get_sint(data, 0) == value + + +@given(st.integers(min_value=0, max_value=65535)) +def test_uint_roundtrip(value: int) -> None: + data = bytearray(2) + set_uint(data, 0, value) + assert get_uint(data, 0) == value + + +@given(st.integers(min_value=-32768, max_value=32767)) +def test_int_roundtrip(value: int) -> None: + data = bytearray(2) + set_int(data, 0, value) + assert get_int(data, 0) == value + + +@given(st.integers(min_value=0, max_value=4294967295)) +def test_dword_roundtrip(value: int) -> None: + data = bytearray(4) + set_dword(data, 0, value) + assert get_dword(data, 0) == value + + +@given(st.integers(min_value=0, max_value=4294967295)) +def test_udint_roundtrip(value: int) -> None: + data = bytearray(4) + set_udint(data, 0, value) + assert get_udint(data, 0) == value + + +@given(st.integers(min_value=-2147483648, max_value=2147483647)) +def test_dint_roundtrip(value: int) -> None: + data = bytearray(4) + set_dint(data, 0, value) + assert get_dint(data, 0) == value + + +@given(st.integers(min_value=0, max_value=2**64 - 1)) +def test_lword_roundtrip(value: int) -> None: + data = bytearray(8) + set_lword(data, 0, value) + assert get_lword(data, 0) == value + + +# --------------------------------------------------------------------------- +# Getter/Setter roundtrip tests — floating point types +# --------------------------------------------------------------------------- + + +@given(st.floats(width=32, allow_nan=False, allow_infinity=False)) +def test_real_roundtrip(value: float) -> None: + data = bytearray(4) + set_real(data, 0, value) + result = get_real(data, 0) + assert struct.pack(">f", value) == struct.pack(">f", result) + + +@given(st.floats(width=64, allow_nan=False, allow_infinity=False)) +def test_lreal_roundtrip(value: float) -> None: + data = bytearray(8) + set_lreal(data, 0, value) + result = get_lreal(data, 0) + assert struct.pack(">d", value) == struct.pack(">d", result) + + +@given(st.floats(width=32, allow_nan=True, allow_infinity=True)) +def test_real_roundtrip_special(value: float) -> None: + """Real roundtrip including NaN and Infinity.""" + data = bytearray(4) + set_real(data, 0, value) + result = get_real(data, 0) + if math.isnan(value): + assert math.isnan(result) + else: + assert result == value + + +@given(st.floats(width=64, allow_nan=True, allow_infinity=True)) +def test_lreal_roundtrip_special(value: float) -> None: + """LReal roundtrip including NaN and Infinity.""" + data = bytearray(8) + set_lreal(data, 0, value) + result = get_lreal(data, 0) + if math.isnan(value): + assert math.isnan(result) + else: + assert result == value + + +# --------------------------------------------------------------------------- +# Getter/Setter roundtrip tests — string types +# --------------------------------------------------------------------------- + + +@given(st.characters(min_codepoint=0, max_codepoint=255)) +def test_char_roundtrip(value: str) -> None: + data = bytearray(1) + set_char(data, 0, value) + assert get_char(data, 0) == value + + +@given(st.characters(min_codepoint=0, max_codepoint=0xFFFF)) +def test_wchar_roundtrip(value: str) -> None: + # wchar uses UTF-16-BE, which can't handle surrogate halves + assume(not (0xD800 <= ord(value) <= 0xDFFF)) + data = bytearray(2) + set_wchar(data, 0, value) + assert get_wchar(data, 0) == value + + +@given(st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=126), min_size=0, max_size=20)) +def test_fstring_roundtrip(value: str) -> None: + max_length = 20 + data = bytearray(max_length) + set_fstring(data, 0, value, max_length) + result = get_fstring(data, 0, max_length) + assert result == value.rstrip(" ") + + +@given(st.text(alphabet=st.characters(min_codepoint=1, max_codepoint=255), min_size=0, max_size=50)) +def test_string_roundtrip(value: str) -> None: + max_size = 254 + buf_size = 2 + max_size + data = bytearray(buf_size) + set_string(data, 0, value, max_size) + assert get_string(data, 0) == value + + +@given(st.text(alphabet=st.characters(max_codepoint=0xFFFF, blacklist_categories=["Cs"]), min_size=0, max_size=20)) +def test_wstring_roundtrip(value: str) -> None: + max_size = 50 + buf_size = 4 + max_size * 2 + data = bytearray(buf_size) + set_wstring(data, 0, value, max_size) + assert get_wstring(data, 0) == value + + +@given(st.text(min_size=1, max_size=5)) +def test_wstring_rejects_supplementary_characters(value: str) -> None: + """Characters outside BMP should be rejected, matching PLC behavior.""" + assume(any(ord(c) > 0xFFFF for c in value)) + data = bytearray(100) + with pytest.raises(ValueError, match="Basic Multilingual Plane"): + set_wstring(data, 0, value, 50) + + +# --------------------------------------------------------------------------- +# Getter/Setter roundtrip tests — date/time types +# --------------------------------------------------------------------------- + + +@given(st.dates(min_value=date(1990, 1, 1), max_value=date(2168, 12, 31))) +def test_date_roundtrip(value: date) -> None: + data = bytearray(2) + set_date(data, 0, value) + assert get_date(data, 0) == value + + +@given( + st.timedeltas( + min_value=timedelta(0), + max_value=timedelta(hours=23, minutes=59, seconds=59, milliseconds=999), + ) +) +def test_tod_roundtrip(value: timedelta) -> None: + # TOD stores milliseconds, so truncate microseconds to ms precision + ms = int(value.total_seconds() * 1000) + value_ms = timedelta(milliseconds=ms) + data = bytearray(4) + set_tod(data, 0, value_ms) + assert get_tod(data, 0) == value_ms + + +@given( + st.datetimes( + min_value=datetime(1990, 1, 1), + max_value=datetime(2089, 12, 31, 23, 59, 59, 999000), + ) +) +def test_dt_roundtrip(value: datetime) -> None: + # DT stores milliseconds, truncate microseconds to ms precision + ms = value.microsecond // 1000 + value_trunc = value.replace(microsecond=ms * 1000) + data = bytearray(8) + set_dt(data, 0, value_trunc) + result = get_date_time_object(data, 0) + assert result == value_trunc + + +@given( + st.datetimes( + min_value=datetime(1, 1, 1), + max_value=datetime(2554, 12, 31, 23, 59, 59, 999000), + ) +) +def test_dtl_roundtrip(value: datetime) -> None: + # DTL stores nanoseconds (microsecond * 1000), so the roundtrip + # preserves microsecond precision exactly. + data = bytearray(12) + set_dtl(data, 0, value) + result = get_dtl(data, 0) + assert result == value + + +# --------------------------------------------------------------------------- +# S7 data type encode/decode roundtrip +# --------------------------------------------------------------------------- + + +@given(st.lists(st.booleans(), min_size=1, max_size=10)) +def test_s7_bit_encode_decode_roundtrip(values: list[bool]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.BIT) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.BIT, len(values)) + assert decoded == values + + +@given(st.lists(st.integers(min_value=0, max_value=255), min_size=1, max_size=10)) +def test_s7_byte_encode_decode_roundtrip(values: list[int]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.BYTE) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.BYTE, len(values)) + assert decoded == values + + +@given(st.lists(st.integers(min_value=0, max_value=65535), min_size=1, max_size=10)) +def test_s7_word_encode_decode_roundtrip(values: list[int]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.WORD) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.WORD, len(values)) + assert decoded == values + + +@given(st.lists(st.integers(min_value=-32768, max_value=32767), min_size=1, max_size=10)) +def test_s7_int_encode_decode_roundtrip(values: list[int]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.INT) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.INT, len(values)) + assert decoded == values + + +@given(st.lists(st.integers(min_value=0, max_value=4294967295), min_size=1, max_size=10)) +def test_s7_dword_encode_decode_roundtrip(values: list[int]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.DWORD) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.DWORD, len(values)) + assert decoded == values + + +@given(st.lists(st.integers(min_value=-2147483648, max_value=2147483647), min_size=1, max_size=10)) +def test_s7_dint_encode_decode_roundtrip(values: list[int]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.DINT) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.DINT, len(values)) + assert decoded == values + + +@given(st.lists(st.floats(width=32, allow_nan=False, allow_infinity=False), min_size=1, max_size=10)) +def test_s7_real_encode_decode_roundtrip(values: list[float]) -> None: + encoded = S7DataTypes.encode_s7_data(values, S7WordLen.REAL) + decoded = S7DataTypes.decode_s7_data(encoded, S7WordLen.REAL, len(values)) + for orig, result in zip(values, decoded): + assert struct.pack(">f", orig) == struct.pack(">f", result) + + +# --------------------------------------------------------------------------- +# S7 address encoding +# --------------------------------------------------------------------------- + + +@given( + st.sampled_from(list(S7Area)), + st.integers(min_value=0, max_value=65535), + st.integers(min_value=0, max_value=65535), + st.sampled_from([wl for wl in S7WordLen if wl not in (S7WordLen.COUNTER, S7WordLen.TIMER)]), + st.integers(min_value=1, max_value=100), +) +def test_address_encoding_is_12_bytes(area: S7Area, db_number: int, start: int, word_len: S7WordLen, count: int) -> None: + """Encoded address should always be exactly 12 bytes.""" + result = S7DataTypes.encode_address(area, db_number, start, word_len, count) + assert len(result) == 12 + assert result[0] == 0x12 # Specification type + assert result[1] == 0x0A # Length + assert result[2] == 0x10 # Syntax ID + + +# --------------------------------------------------------------------------- +# TPKT frame tests +# --------------------------------------------------------------------------- + + +@given(st.binary(min_size=1, max_size=500)) +def test_tpkt_frame_structure(payload: bytes) -> None: + """TPKT frame should have correct version, reserved byte, and length.""" + from snap7.connection import ISOTCPConnection + + conn = ISOTCPConnection.__new__(ISOTCPConnection) + frame = conn._build_tpkt(payload) + assert frame[0] == 3 # version + assert frame[1] == 0 # reserved + length = struct.unpack(">H", frame[2:4])[0] + assert length == len(payload) + 4 + assert frame[4:] == payload + + +@given(st.binary(min_size=1, max_size=500)) +def test_cotp_dt_frame_structure(payload: bytes) -> None: + """COTP DT frame should have correct PDU type and EOT marker.""" + from snap7.connection import ISOTCPConnection + + conn = ISOTCPConnection.__new__(ISOTCPConnection) + frame = conn._build_cotp_dt(payload) + assert frame[0] == 2 # PDU length + assert frame[1] == 0xF0 # COTP DT type + assert frame[2] == 0x80 # EOT + sequence number 0 + assert frame[3:] == payload + + +# --------------------------------------------------------------------------- +# S7 Protocol PDU structure tests +# --------------------------------------------------------------------------- + + +@given( + st.sampled_from(list(S7Area)), + st.integers(min_value=0, max_value=100), + st.integers(min_value=0, max_value=1000), + st.sampled_from([wl for wl in S7WordLen if wl not in (S7WordLen.COUNTER, S7WordLen.TIMER)]), + st.integers(min_value=1, max_value=50), +) +def test_read_request_pdu_structure(area: S7Area, db_number: int, start: int, word_len: S7WordLen, count: int) -> None: + """Read request PDU should have valid S7 header.""" + proto = S7Protocol() + pdu = proto.build_read_request(area, db_number, start, word_len, count) + assert pdu[0] == 0x32 # Protocol ID + assert pdu[1] == 0x01 # Request PDU type + assert len(pdu) >= 12 # Minimum header size + + +@given( + st.sampled_from(list(S7Area)), + st.integers(min_value=0, max_value=100), + st.integers(min_value=0, max_value=1000), + st.sampled_from([S7WordLen.BYTE, S7WordLen.WORD, S7WordLen.DWORD, S7WordLen.INT, S7WordLen.DINT, S7WordLen.REAL]), + st.binary(min_size=1, max_size=20), +) +def test_write_request_pdu_structure(area: S7Area, db_number: int, start: int, word_len: S7WordLen, data: bytes) -> None: + """Write request PDU should have valid S7 header.""" + item_size = S7DataTypes.get_size_bytes(word_len, 1) + # Ensure data length is a multiple of item size + data = data[: (len(data) // item_size) * item_size] + assume(len(data) > 0) + + proto = S7Protocol() + pdu = proto.build_write_request(area, db_number, start, word_len, data) + assert pdu[0] == 0x32 # Protocol ID + assert pdu[1] == 0x01 # Request PDU type + + +# --------------------------------------------------------------------------- +# Fuzz tests — robustness against arbitrary input +# --------------------------------------------------------------------------- + + +@given(st.binary(min_size=4, max_size=4)) +def test_real_decode_no_crash(data: bytes) -> None: + """Any 4 bytes should decode without crashing.""" + get_real(bytearray(data), 0) + + +@given(st.binary(min_size=8, max_size=8)) +def test_lreal_decode_no_crash(data: bytes) -> None: + """Any 8 bytes should decode without crashing.""" + get_lreal(bytearray(data), 0) + + +@given(st.binary(min_size=2, max_size=2)) +def test_int_decode_no_crash(data: bytes) -> None: + """Any 2 bytes should decode without crashing.""" + get_int(bytearray(data), 0) + + +@given(st.binary(min_size=4, max_size=4)) +def test_dint_decode_no_crash(data: bytes) -> None: + """Any 4 bytes should decode without crashing.""" + get_dint(bytearray(data), 0) + + +@given(st.binary(min_size=8, max_size=8)) +def test_lint_decode_no_crash(data: bytes) -> None: + """Any 8 bytes should decode without crashing.""" + get_lint(bytearray(data), 0) + + +@given(st.binary(min_size=8, max_size=8)) +def test_lword_decode_no_crash(data: bytes) -> None: + """Any 8 bytes should decode without crashing.""" + get_lword(bytearray(data), 0) + + +@given(st.binary(min_size=8, max_size=8)) +def test_ulint_decode_no_crash(data: bytes) -> None: + """Any 8 bytes should decode without crashing.""" + get_ulint(bytearray(data), 0) + + +@given(st.binary(min_size=10, max_size=500)) +@settings(suppress_health_check=[HealthCheck.too_slow]) +def test_pdu_parse_no_crash(data: bytes) -> None: + """Parsing random bytes as S7 PDU should not crash unexpectedly. + + Expected exceptions are S7ProtocolError for invalid data. + """ + from snap7.error import S7ProtocolError + + proto = S7Protocol() + try: + proto.parse_response(data) + except (S7ProtocolError, struct.error, ValueError, IndexError): + pass # Expected for malformed data + + +@given(st.binary(min_size=7, max_size=100)) +def test_tpkt_cotp_parse_no_crash(data: bytes) -> None: + """Parsing random bytes as COTP data should not crash unexpectedly.""" + from snap7.connection import ISOTCPConnection + from snap7.error import S7ConnectionError + + conn = ISOTCPConnection.__new__(ISOTCPConnection) + try: + conn._parse_cotp_data(data) + except (ValueError, IndexError, struct.error, S7ConnectionError): + pass # Expected for malformed data + + +# --------------------------------------------------------------------------- +# Multiple bools in the same byte don't interfere +# --------------------------------------------------------------------------- + + +@given(st.lists(st.booleans(), min_size=8, max_size=8)) +def test_bool_multiple_bits_no_interference(values: list[bool]) -> None: + """Setting 8 bools in one byte should not interfere with each other.""" + data = bytearray(1) + for i, v in enumerate(values): + set_bool(data, 0, i, v) + for i, v in enumerate(values): + assert get_bool(data, 0, i) == v + + +# --------------------------------------------------------------------------- +# Non-zero byte_index tests +# --------------------------------------------------------------------------- + + +@given(st.integers(min_value=-32768, max_value=32767), st.integers(min_value=0, max_value=10)) +def test_int_roundtrip_at_offset(value: int, offset: int) -> None: + """Getter/setter should work at arbitrary byte offsets.""" + data = bytearray(offset + 2) + set_int(data, offset, value) + assert get_int(data, offset) == value + + +@given(st.integers(min_value=-2147483648, max_value=2147483647), st.integers(min_value=0, max_value=10)) +def test_dint_roundtrip_at_offset(value: int, offset: int) -> None: + data = bytearray(offset + 4) + set_dint(data, offset, value) + assert get_dint(data, offset) == value diff --git a/uv.lock b/uv.lock index 38c470c2..0577d372 100644 --- a/uv.lock +++ b/uv.lock @@ -335,6 +335,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -780,6 +793,7 @@ doc = [ { name = "sphinx-rtd-theme" }, ] test = [ + { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -796,6 +810,7 @@ test = [ [package.metadata] requires-dist = [ { name = "click", marker = "extra == 'cli'" }, + { name = "hypothesis", marker = "extra == 'test'" }, { name = "mypy", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, @@ -884,6 +899,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sphinx" version = "8.1.3"