From f47414dcd99e0679a61300e8fb65fe24264cd4d4 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 1 Sep 2024 21:29:54 +0100 Subject: [PATCH 01/98] Replacing `httptools` with `h11`. (#244) --- mocket/mockhttp.py | 61 +++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/mocket/mockhttp.py b/mocket/mockhttp.py index 25540915..a5b47cac 100644 --- a/mocket/mockhttp.py +++ b/mocket/mockhttp.py @@ -1,9 +1,11 @@ import re import time +from functools import cached_property from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, unquote, urlsplit -from httptools.parser import HttpRequestParser +from h11 import SERVER, Connection, Data +from h11 import Request as H11Request from .compat import ENCODING, decode_from_bytes, do_the_magic, encode_to_bytes from .mocket import Mocket, MocketEntry @@ -19,61 +21,54 @@ ASCII = "ascii" -class Protocol: - def __init__(self): - self.url = None - self.body = None - self.headers = {} - - def on_header(self, name: bytes, value: bytes): - self.headers[name.decode(ASCII)] = value.decode(ASCII) - - def on_body(self, body: bytes): - try: - self.body = body.decode(ENCODING) - except UnicodeDecodeError: - self.body = body - - def on_url(self, url: bytes): - self.url = url.decode(ASCII) - - class Request: - _protocol = None _parser = None + _event = None def __init__(self, data): - self._protocol = Protocol() - self._parser = HttpRequestParser(self._protocol) + self._parser = Connection(SERVER) self.add_data(data) def add_data(self, data): - self._parser.feed_data(data) + self._parser.receive_data(data) @property + def event(self): + if not self._event: + event = self._parser.next_event() + self._event = event + return self._event + + @cached_property def method(self): - return self._parser.get_method().decode(ASCII) + return self.event.method.decode(ASCII) - @property + @cached_property def path(self): - return self._protocol.url + return self.event.target.decode(ASCII) - @property + @cached_property def headers(self): - return self._protocol.headers + return {k.decode(ASCII): v.decode(ASCII) for k, v in self.event.headers} - @property + @cached_property def querystring(self): - parts = self._protocol.url.split("?", 1) + parts = self.path.split("?", 1) return ( parse_qs(unquote(parts[1]), keep_blank_values=True) if len(parts) == 2 else {} ) - @property + @cached_property def body(self): - return self._protocol.body + while True: + event = self._parser.next_event() + if isinstance(event, H11Request): + self._event = event + elif isinstance(event, Data): + break + return event.data.decode(ENCODING) def __str__(self): return f"{self.method} - {self.path} - {self.headers}" diff --git a/pyproject.toml b/pyproject.toml index 203184cf..34e887b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "python-magic>=0.4.5", "decorator>=4.0.0", "urllib3>=1.25.3", - "httptools", + "h11", ] dynamic = ["version"] From b9ccae4846b1c0213ec9822ded6e5ae57265c3d3 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 1 Sep 2024 22:38:53 +0200 Subject: [PATCH 02/98] Bump version --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 2a5c3f40..6335ce87 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -3,4 +3,4 @@ __all__ = ("async_mocketize", "mocketize", "Mocket", "MocketEntry", "Mocketizer") -__version__ = "3.12.8" +__version__ = "3.12.9" From a42efda90c24f8e6933bfd3e50fb94e6b9a73d5b Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 3 Sep 2024 11:20:45 +0200 Subject: [PATCH 03/98] Small refactor. --- mocket/mockhttp.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mocket/mockhttp.py b/mocket/mockhttp.py index a5b47cac..fceb831f 100644 --- a/mocket/mockhttp.py +++ b/mocket/mockhttp.py @@ -35,8 +35,7 @@ def add_data(self, data): @property def event(self): if not self._event: - event = self._parser.next_event() - self._event = event + self._event = self._parser.next_event() return self._event @cached_property @@ -67,8 +66,7 @@ def body(self): if isinstance(event, H11Request): self._event = event elif isinstance(event, Data): - break - return event.data.decode(ENCODING) + return event.data.decode(ENCODING) def __str__(self): return f"{self.method} - {self.path} - {self.headers}" From 90e8fbbdf7cade279d68059773e142c01b46a2ad Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 16 Sep 2024 09:10:33 +0100 Subject: [PATCH 04/98] Testing Python 3.13 (#242) * Adding `Python 3.13`. * Fix for tests. * Bump version --- .github/workflows/main.yml | 2 +- mocket/__init__.py | 2 +- mocket/mocket.py | 5 +++-- pyproject.toml | 1 + tests/main/test_http.py | 12 ++++++++++-- tests/main/test_http_with_xxhash.py | 12 ++++++++++-- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ff623d0..1f1c8efa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] # , 'pypy3.10' + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', ' 3.13.0-rc.2', 'pypy3.10'] steps: - uses: actions/checkout@v4 diff --git a/mocket/__init__.py b/mocket/__init__.py index 6335ce87..00284855 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -3,4 +3,4 @@ __all__ = ("async_mocketize", "mocketize", "Mocket", "MocketEntry", "Mocketizer") -__version__ = "3.12.9" +__version__ = "3.13.0" diff --git a/mocket/mocket.py b/mocket/mocket.py index cca0a4cd..daa0e608 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -64,7 +64,7 @@ true_socketpair = socket.socketpair true_ssl_wrap_socket = getattr( ssl, "wrap_socket", None -) # in Py3.12 it's only under SSLContext +) # from Py3.12 it's only under SSLContext true_ssl_socket = ssl.SSLSocket true_ssl_context = ssl.SSLContext true_inet_pton = socket.inet_pton @@ -83,6 +83,7 @@ def __set__(self, *args): minimum_version = FakeSetter() options = FakeSetter() verify_mode = FakeSetter() + verify_flags = FakeSetter() class FakeSSLContext(SuperFakeSSLContext): @@ -102,7 +103,7 @@ def check_hostname(self): return self._check_hostname @check_hostname.setter - def check_hostname(self, *args): + def check_hostname(self, _): self._check_hostname = False def __init__(self, sock=None, server_hostname=None, _context=None, *args, **kwargs): diff --git a/pyproject.toml b/pyproject.toml index 34e887b9..cdb4d9b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development", diff --git a/tests/main/test_http.py b/tests/main/test_http.py index 21fe448d..d335bcc5 100644 --- a/tests/main/test_http.py +++ b/tests/main/test_http.py @@ -308,10 +308,18 @@ def test_request_bodies(self): @mocketize(truesocket_recording_dir=os.path.dirname(__file__)) def test_truesendall_with_dump_from_recording(self): requests.get( - "http://httpbin.local/ip", headers={"user-agent": "Fake-User-Agent"} + "http://httpbin.local/ip", + headers={ + "user-agent": "Fake-User-Agent", + "Accept-Encoding": "gzip, deflate, zstd", + }, ) requests.get( - "http://httpbin.local/gzip", headers={"user-agent": "Fake-User-Agent"} + "http://httpbin.local/gzip", + headers={ + "user-agent": "Fake-User-Agent", + "Accept-Encoding": "gzip, deflate, zstd", + }, ) dump_filename = os.path.join( diff --git a/tests/main/test_http_with_xxhash.py b/tests/main/test_http_with_xxhash.py index 4600bf37..a074a864 100644 --- a/tests/main/test_http_with_xxhash.py +++ b/tests/main/test_http_with_xxhash.py @@ -11,10 +11,18 @@ class HttpEntryTestCase(HttpTestCase): @mocketize(truesocket_recording_dir=os.path.dirname(__file__)) def test_truesendall_with_dump_from_recording(self): requests.get( - "http://httpbin.local/ip", headers={"user-agent": "Fake-User-Agent"} + "http://httpbin.local/ip", + headers={ + "user-agent": "Fake-User-Agent", + "Accept-Encoding": "gzip, deflate, zstd", + }, ) requests.get( - "http://httpbin.local/gzip", headers={"user-agent": "Fake-User-Agent"} + "http://httpbin.local/gzip", + headers={ + "user-agent": "Fake-User-Agent", + "Accept-Encoding": "gzip, deflate, zstd", + }, ) dump_filename = os.path.join( From 4a1ed42375df8c44ece10aa56cd630c04619b194 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 16 Sep 2024 11:13:36 +0200 Subject: [PATCH 05/98] Fix publishing. --- Makefile | 10 +++++----- tests/main/test_http.py | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 675a0fa0..159d3881 100644 --- a/Makefile +++ b/Makefile @@ -36,13 +36,13 @@ test: types safetest: export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; make test -publish: install-test-requirements - python -m build --sdist . - twine upload --repository mocket dist/mocket-$(shell python -c 'import mocket; print(mocket.__version__)').tar.gz +publish: clean install-test-requirements + uv run python3 -m build --sdist . + uv run twine upload --repository mocket dist/*.tar.gz clean: - rm -rf *.egg-info dist/ requirements.txt Pipfile.lock - find . -type d -name __pycache__ -exec rm -rf {} \; + rm -rf *.egg-info dist/ requirements.txt Pipfile.lock || true + find . -type d -name __pycache__ -exec rm -rf {} \; || true .PHONY: clean publish safetest test setup develop lint-python test-python _services-up .PHONY: prepare-hosts services-up services-down install-test-requirements install-dev-requirements diff --git a/tests/main/test_http.py b/tests/main/test_http.py index d335bcc5..27324106 100644 --- a/tests/main/test_http.py +++ b/tests/main/test_http.py @@ -271,8 +271,7 @@ def test_file_object_with_no_lib_magic(self): self.assertEqual(remote_content, local_content) self.assertEqual(len(remote_content), len(local_content)) self.assertEqual(int(r.headers["Content-Length"]), len(local_content)) - with self.assertRaises(KeyError): - self.assertEqual(r.headers["Content-Type"], "image/png") + self.assertNotIn("Content-Type", r.headers) @mocketize def test_same_url_different_methods(self): From 23e0a535e34e97ea168db684d985b0d8137c556c Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 19 Sep 2024 20:59:39 +0100 Subject: [PATCH 06/98] No need to keep multiple dirs for tests. (#245) --- Makefile | 2 +- tests/main/__init__.py | 0 tests/{main => }/test_asyncio.py | 0 tests/{main => }/test_http.py | 0 tests/{main => }/test_http_gevent.py | 2 +- tests/{tests38 => }/test_http_httpx.py | 0 tests/{main => }/test_http_with_xxhash.py | 2 +- tests/{main => }/test_httpretty.py | 0 tests/{main => }/test_https.py | 0 tests/{main => }/test_httpx.py | 0 tests/{main => }/test_mocket.py | 0 tests/{main => }/test_mode.py | 0 tests/{main => }/test_pook.py | 0 tests/{main => }/test_redis.py | 0 tests/{main => }/test_socket.py | 0 ...ntryTestCase.test_truesendall_with_dump_from_recording.json} | 0 ...ntryTestCase.test_truesendall_with_dump_from_recording.json} | 0 ...ntryTestCase.test_truesendall_with_dump_from_recording.json} | 0 tests/tests38/README.txt | 1 - tests/tests38/__init__.py | 0 20 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 tests/main/__init__.py rename tests/{main => }/test_asyncio.py (100%) rename tests/{main => }/test_http.py (100%) rename tests/{main => }/test_http_gevent.py (68%) rename tests/{tests38 => }/test_http_httpx.py (100%) rename tests/{main => }/test_http_with_xxhash.py (95%) rename tests/{main => }/test_httpretty.py (100%) rename tests/{main => }/test_https.py (100%) rename tests/{main => }/test_httpx.py (100%) rename tests/{main => }/test_mocket.py (100%) rename tests/{main => }/test_mode.py (100%) rename tests/{main => }/test_pook.py (100%) rename tests/{main => }/test_redis.py (100%) rename tests/{main => }/test_socket.py (100%) rename tests/{main/tests.main.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json => tests.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json} (100%) rename tests/{main/tests.main.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json => tests.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json} (100%) rename tests/{main/tests.main.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json => tests.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json} (100%) delete mode 100644 tests/tests38/README.txt delete mode 100644 tests/tests38/__init__.py diff --git a/Makefile b/Makefile index 159d3881..f344591f 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ publish: clean install-test-requirements uv run twine upload --repository mocket dist/*.tar.gz clean: - rm -rf *.egg-info dist/ requirements.txt Pipfile.lock || true + rm -rf *.egg-info dist/ requirements.txt uv.lock || true find . -type d -name __pycache__ -exec rm -rf {} \; || true .PHONY: clean publish safetest test setup develop lint-python test-python _services-up diff --git a/tests/main/__init__.py b/tests/main/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/main/test_asyncio.py b/tests/test_asyncio.py similarity index 100% rename from tests/main/test_asyncio.py rename to tests/test_asyncio.py diff --git a/tests/main/test_http.py b/tests/test_http.py similarity index 100% rename from tests/main/test_http.py rename to tests/test_http.py diff --git a/tests/main/test_http_gevent.py b/tests/test_http_gevent.py similarity index 68% rename from tests/main/test_http_gevent.py rename to tests/test_http_gevent.py index 88233071..4a3a9ff1 100644 --- a/tests/main/test_http_gevent.py +++ b/tests/test_http_gevent.py @@ -1,6 +1,6 @@ from gevent import monkey -from tests.main.test_http import HttpEntryTestCase +from tests.test_http import HttpEntryTestCase monkey.patch_socket() diff --git a/tests/tests38/test_http_httpx.py b/tests/test_http_httpx.py similarity index 100% rename from tests/tests38/test_http_httpx.py rename to tests/test_http_httpx.py diff --git a/tests/main/test_http_with_xxhash.py b/tests/test_http_with_xxhash.py similarity index 95% rename from tests/main/test_http_with_xxhash.py rename to tests/test_http_with_xxhash.py index a074a864..76d85343 100644 --- a/tests/main/test_http_with_xxhash.py +++ b/tests/test_http_with_xxhash.py @@ -4,7 +4,7 @@ import requests from mocket import Mocket, mocketize -from tests.main.test_http import HttpTestCase +from tests.test_http import HttpTestCase class HttpEntryTestCase(HttpTestCase): diff --git a/tests/main/test_httpretty.py b/tests/test_httpretty.py similarity index 100% rename from tests/main/test_httpretty.py rename to tests/test_httpretty.py diff --git a/tests/main/test_https.py b/tests/test_https.py similarity index 100% rename from tests/main/test_https.py rename to tests/test_https.py diff --git a/tests/main/test_httpx.py b/tests/test_httpx.py similarity index 100% rename from tests/main/test_httpx.py rename to tests/test_httpx.py diff --git a/tests/main/test_mocket.py b/tests/test_mocket.py similarity index 100% rename from tests/main/test_mocket.py rename to tests/test_mocket.py diff --git a/tests/main/test_mode.py b/tests/test_mode.py similarity index 100% rename from tests/main/test_mode.py rename to tests/test_mode.py diff --git a/tests/main/test_pook.py b/tests/test_pook.py similarity index 100% rename from tests/main/test_pook.py rename to tests/test_pook.py diff --git a/tests/main/test_redis.py b/tests/test_redis.py similarity index 100% rename from tests/main/test_redis.py rename to tests/test_redis.py diff --git a/tests/main/test_socket.py b/tests/test_socket.py similarity index 100% rename from tests/main/test_socket.py rename to tests/test_socket.py diff --git a/tests/main/tests.main.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json b/tests/tests.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json similarity index 100% rename from tests/main/tests.main.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json rename to tests/tests.test_http.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json diff --git a/tests/main/tests.main.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json b/tests/tests.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json similarity index 100% rename from tests/main/tests.main.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json rename to tests/tests.test_http_gevent.GeventHttpEntryTestCase.test_truesendall_with_dump_from_recording.json diff --git a/tests/main/tests.main.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json b/tests/tests.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json similarity index 100% rename from tests/main/tests.main.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json rename to tests/tests.test_http_with_xxhash.HttpEntryTestCase.test_truesendall_with_dump_from_recording.json diff --git a/tests/tests38/README.txt b/tests/tests38/README.txt deleted file mode 100644 index 9d9332be..00000000 --- a/tests/tests38/README.txt +++ /dev/null @@ -1 +0,0 @@ -Since IsolatedAsyncioTestCase is only available on Python >= 3.8, these tests won't be available to builds using previous versions. diff --git a/tests/tests38/__init__.py b/tests/tests38/__init__.py deleted file mode 100644 index e69de29b..00000000 From bf397c08e465d48182c9fabb6e67a9d3c8bedecb Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 19 Sep 2024 21:23:04 +0100 Subject: [PATCH 07/98] Back to testing doctest. (#246) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cdb4d9b9..fc162743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ exclude = [ [tool.pytest.ini_options] testpaths = [ - "tests", + "tests", "mocket", ] addopts = "--doctest-modules --cov=mocket --cov-report=term-missing -v -x" From d49022a03cddaaba96e6c41702e8d1289e91b1b8 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 26 Sep 2024 09:17:52 +0100 Subject: [PATCH 08/98] Pinning the version of `aiohttp` (#248) * Pinning the version due to #247. * Fix for testing file descriptors. --- pyproject.toml | 2 +- tests/test_mocket.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc162743..fbd191b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ test = [ "build", "twine", "fastapi", - "aiohttp", + "aiohttp<3.10.6", "wait-for-it", "mypy", "types-decorator", diff --git a/tests/test_mocket.py b/tests/test_mocket.py index e6116dd1..8d09f170 100644 --- a/tests/test_mocket.py +++ b/tests/test_mocket.py @@ -233,4 +233,4 @@ async def test_no_dangling_fds(): async with Mocketizer(strict_mode=False), httpx.AsyncClient() as client: await client.get(url) - assert proc.num_fds() == prev_num_fds + assert proc.num_fds() <= prev_num_fds From 13b4677cc3af8ae65e7f2ce875b38258ed27b874 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Fri, 18 Oct 2024 09:51:48 +0200 Subject: [PATCH 09/98] Update main.yml (#253) --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f1c8efa..c4b8987f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', ' 3.13.0-rc.2', 'pypy3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', ' 3.13', 'pypy3.10'] steps: - uses: actions/checkout@v4 From e7e8172a5824b3a44f2e793f6f374e5efc5eb563 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 19 Oct 2024 11:20:18 +0200 Subject: [PATCH 10/98] Switching to using `puremagic` for identifying MIME types. (#255) --- mocket/__init__.py | 2 +- mocket/compat.py | 17 ++++++++--------- mocket/mockhttp.py | 15 +++------------ pyproject.toml | 2 +- tests/test_compat.py | 5 +++++ tests/test_http.py | 15 --------------- 6 files changed, 18 insertions(+), 38 deletions(-) create mode 100644 tests/test_compat.py diff --git a/mocket/__init__.py b/mocket/__init__.py index 00284855..31cd56fc 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -3,4 +3,4 @@ __all__ = ("async_mocketize", "mocketize", "Mocket", "MocketEntry", "Mocketizer") -__version__ = "3.13.0" +__version__ = "3.13.1" diff --git a/mocket/compat.py b/mocket/compat.py index 8457c274..276ae0f0 100644 --- a/mocket/compat.py +++ b/mocket/compat.py @@ -5,6 +5,8 @@ import shlex from typing import Final +import puremagic + ENCODING: Final[str] = os.getenv("MOCKET_ENCODING", "utf-8") text_type = str @@ -29,12 +31,9 @@ def shsplit(s: str | bytes) -> list[str]: return shlex.split(s) -def do_the_magic(lib_magic, body): # pragma: no cover - if hasattr(lib_magic, "from_buffer"): - # PyPI python-magic - return lib_magic.from_buffer(body, mime=True) - # file's builtin python wrapper - # used by https://www.archlinux.org/packages/community/any/python-mocket/ - _magic = lib_magic.open(lib_magic.MAGIC_MIME_TYPE) - _magic.load() - return _magic.buffer(body) +def do_the_magic(body): + try: + magic = puremagic.magic_string(body) + except puremagic.PureError: + magic = [] + return magic[0].mime_type if len(magic) else "application/octet-stream" diff --git a/mocket/mockhttp.py b/mocket/mockhttp.py index fceb831f..5058328d 100644 --- a/mocket/mockhttp.py +++ b/mocket/mockhttp.py @@ -10,12 +10,6 @@ from .compat import ENCODING, decode_from_bytes, do_the_magic, encode_to_bytes from .mocket import Mocket, MocketEntry -try: - import magic -except ImportError: - magic = None - - STATUS = {k: v[0] for k, v in BaseHTTPRequestHandler.responses.items()} CRLF = "\r\n" ASCII = "ascii" @@ -76,10 +70,7 @@ class Response: headers = None is_file_object = False - def __init__(self, body="", status=200, headers=None, lib_magic=magic): - # needed for testing libmagic import failure - self.magic = lib_magic - + def __init__(self, body="", status=200, headers=None): headers = headers or {} try: # File Objects @@ -116,8 +107,8 @@ def set_base_headers(self): } if not self.is_file_object: self.headers["Content-Type"] = f"text/plain; charset={ENCODING}" - elif self.magic: - self.headers["Content-Type"] = do_the_magic(self.magic, self.body) + else: + self.headers["Content-Type"] = do_the_magic(self.body) def set_extra_headers(self, headers): r""" diff --git a/pyproject.toml b/pyproject.toml index fbd191b9..e3b7d866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ "License :: OSI Approved :: BSD License", ] dependencies = [ - "python-magic>=0.4.5", + "puremagic", "decorator>=4.0.0", "urllib3>=1.25.3", "h11", diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 00000000..49b62ec7 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,5 @@ +from mocket.compat import do_the_magic + + +def test_unknown_binary(): + assert do_the_magic(b"foobar-binary") == "application/octet-stream" diff --git a/tests/test_http.py b/tests/test_http.py index 27324106..d516068b 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -258,21 +258,6 @@ def test_file_object(self): self.assertEqual(int(r.headers["Content-Length"]), len(local_content)) self.assertEqual(r.headers["Content-Type"], "image/png") - @mocketize - def test_file_object_with_no_lib_magic(self): - url = "http://github.com/fluidicon.png" - filename = "tests/fluidicon.png" - with open(filename, "rb") as file_obj: - Entry.register(Entry.GET, url, Response(body=file_obj, lib_magic=None)) - r = requests.get(url) - remote_content = r.content - with open(filename, "rb") as local_file_obj: - local_content = local_file_obj.read() - self.assertEqual(remote_content, local_content) - self.assertEqual(len(remote_content), len(local_content)) - self.assertEqual(int(r.headers["Content-Length"]), len(local_content)) - self.assertNotIn("Content-Type", r.headers) - @mocketize def test_same_url_different_methods(self): url = "http://bit.ly/fakeurl" From 17fd151362a09db9c367281aef4e69ef008fc42a Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 19 Oct 2024 18:01:21 +0200 Subject: [PATCH 11/98] Test the plugin for `pook` separately (#256) * Test the plugin for `pook` separately. * Better Makefile. --- Makefile | 4 ++- mocket/mocket.py | 12 -------- mocket/plugins/pook_mock_engine.py | 29 ++++++++++--------- pyproject.toml | 5 ++-- tests/test_asyncio.py | 3 +- tests/test_pook.py | 46 ++++++++++++++++-------------- 6 files changed, 47 insertions(+), 52 deletions(-) diff --git a/Makefile b/Makefile index f344591f..62c52dc7 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,9 @@ types: test: types @echo "Running Python tests" + uv pip uninstall pook || true export VIRTUAL_ENV=.venv; .venv/bin/wait-for-it --service httpbin.local:443 --service localhost:6379 --timeout 5 -- .venv/bin/pytest + uv pip install pook && .venv/bin/pytest tests/test_pook.py && uv pip uninstall pook @echo "" safetest: @@ -41,7 +43,7 @@ publish: clean install-test-requirements uv run twine upload --repository mocket dist/*.tar.gz clean: - rm -rf *.egg-info dist/ requirements.txt uv.lock || true + rm -rf .coverage *.egg-info dist/ requirements.txt uv.lock || true find . -type d -name __pycache__ -exec rm -rf {} \; || true .PHONY: clean publish safetest test setup develop lint-python test-python _services-up diff --git a/mocket/mocket.py b/mocket/mocket.py index daa0e608..cbd42ca9 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -48,14 +48,6 @@ except ImportError: pyopenssl_override = False -try: # pragma: no cover - from aiohttp import TCPConnector - - aiohttp_make_ssl_context_cache_clear = TCPConnector._make_ssl_context.cache_clear -except (ImportError, AttributeError): - aiohttp_make_ssl_context_cache_clear = None - - true_socket = socket.socket true_create_connection = socket.create_connection true_gethostbyname = socket.gethostbyname @@ -566,8 +558,6 @@ def enable(namespace=None, truesocket_recording_dir=None): if pyopenssl_override: # pragma: no cover # Take out the pyopenssl version - use the default implementation extract_from_urllib3() - if aiohttp_make_ssl_context_cache_clear: # pragma: no cover - aiohttp_make_ssl_context_cache_clear() @staticmethod def disable(): @@ -604,8 +594,6 @@ def disable(): if pyopenssl_override: # pragma: no cover # Put the pyopenssl version back in place inject_into_urllib3() - if aiohttp_make_ssl_context_cache_clear: # pragma: no cover - aiohttp_make_ssl_context_cache_clear() @classmethod def get_namespace(cls): diff --git a/mocket/plugins/pook_mock_engine.py b/mocket/plugins/pook_mock_engine.py index 99cb07ec..549f5509 100644 --- a/mocket/plugins/pook_mock_engine.py +++ b/mocket/plugins/pook_mock_engine.py @@ -1,5 +1,7 @@ -from pook.engine import MockEngine -from pook.interceptors.base import BaseInterceptor +try: + from pook.engine import MockEngine +except ModuleNotFoundError: + MockEngine = object from mocket.mocket import Mocket from mocket.mockhttp import Entry, Response @@ -37,17 +39,6 @@ def single_register( return entry -class MocketInterceptor(BaseInterceptor): - @staticmethod - def activate(): - Mocket.disable() - Mocket.enable() - - @staticmethod - def disable(): - Mocket.disable() - - class MocketEngine(MockEngine): def __init__(self, engine): def mocket_mock_fun(*args, **kwargs): @@ -68,6 +59,18 @@ def mocket_mock_fun(*args, **kwargs): return mock + from pook.interceptors.base import BaseInterceptor + + class MocketInterceptor(BaseInterceptor): + @staticmethod + def activate(): + Mocket.disable() + Mocket.enable() + + @staticmethod + def disable(): + Mocket.disable() + # Store plugins engine self.engine = engine # Store HTTP client interceptors diff --git a/pyproject.toml b/pyproject.toml index e3b7d866..77d1f5d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ test = [ "redis", "gevent", "sure", - "pook", "flake8>5", "xxhash", "httpx", @@ -54,7 +53,7 @@ test = [ "build", "twine", "fastapi", - "aiohttp<3.10.6", + "aiohttp", "wait-for-it", "mypy", "types-decorator", @@ -89,7 +88,7 @@ exclude = [ testpaths = [ "tests", "mocket", ] -addopts = "--doctest-modules --cov=mocket --cov-report=term-missing -v -x" +addopts = "--doctest-modules --cov=mocket --cov-report=term-missing --cov-append -v -x" [tool.ruff] src = ["mocket", "tests"] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 0f9a7d17..59dd474e 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -4,7 +4,6 @@ import socket import tempfile -import aiohttp import pytest from mocket import Mocketizer, async_mocketize @@ -46,6 +45,8 @@ async def test_asyncio_connection(): @pytest.mark.asyncio @async_mocketize async def test_aiohttp(): + import aiohttp + url = "https://bar.foo/" data = {"message": "Hello"} diff --git a/tests/test_pook.py b/tests/test_pook.py index f398672e..56721b5f 100644 --- a/tests/test_pook.py +++ b/tests/test_pook.py @@ -1,29 +1,31 @@ -import pook -import requests +import contextlib -from mocket.plugins.pook_mock_engine import MocketEngine +with contextlib.suppress(ModuleNotFoundError): + import pook + import requests -pook.set_mock_engine(MocketEngine) + from mocket.plugins.pook_mock_engine import MocketEngine + pook.set_mock_engine(MocketEngine) -@pook.on -def test_pook_engine(): - url = "http://twitter.com/api/1/foobar" - status = 404 - response_json = {"error": "foo"} + @pook.on + def test_pook_engine(): + url = "http://twitter.com/api/1/foobar" + status = 404 + response_json = {"error": "foo"} - mock = pook.get( - url, - headers={"content-type": "application/json"}, - reply=status, - response_json=response_json, - ) - mock.persist() + mock = pook.get( + url, + headers={"content-type": "application/json"}, + reply=status, + response_json=response_json, + ) + mock.persist() - requests.get(url) - assert mock.calls == 1 + requests.get(url) + assert mock.calls == 1 - resp = requests.get(url) - assert resp.status_code == status - assert resp.json() == response_json - assert mock.calls == 2 + resp = requests.get(url) + assert resp.status_code == status + assert resp.json() == response_json + assert mock.calls == 2 From ea5ad794760e9b562f788be90954a49f02916d42 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 20 Oct 2024 11:09:03 +0200 Subject: [PATCH 12/98] Refactoring FakeSSLContext. (#257) --- mocket/mocket.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/mocket/mocket.py b/mocket/mocket.py index cbd42ca9..570150a8 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -25,7 +25,6 @@ from .compat import basestring, byte_type, decode_from_bytes, encode_to_bytes, text_type from .utils import ( - SSL_PROTOCOL, MocketMode, MocketSocketCore, get_mocketize, @@ -98,20 +97,9 @@ def check_hostname(self): def check_hostname(self, _): self._check_hostname = False - def __init__(self, sock=None, server_hostname=None, _context=None, *args, **kwargs): + def __init__(self, *args, **kwargs): self._set_dummy_methods() - if isinstance(sock, MocketSocket): - self.sock = sock - self.sock._host = server_hostname - self.sock.true_socket = true_ssl_socket( - sock=self.sock.true_socket, - server_hostname=server_hostname, - _context=true_ssl_context(protocol=SSL_PROTOCOL), - ) - elif isinstance(sock, int) and true_ssl_context: - self.context = true_ssl_context(sock) - def _set_dummy_methods(self): def dummy_method(*args, **kwargs): pass @@ -120,7 +108,7 @@ def dummy_method(*args, **kwargs): setattr(self, m, dummy_method) @staticmethod - def wrap_socket(sock=sock, *args, **kwargs): + def wrap_socket(sock, *args, **kwargs): sock.kwargs = kwargs sock._secure_socket = True return sock @@ -131,10 +119,6 @@ def wrap_bio(incoming, outcoming, *args, **kwargs): ssl_obj._host = kwargs["server_hostname"] return ssl_obj - def __getattr__(self, name): - if self.sock is not None: - return getattr(self.sock, name) - def create_connection(address, timeout=None, source_address=None): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) From d0b43a1661b2a90b20351d97ea1c79b7255eb2d0 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 20 Oct 2024 12:52:51 +0200 Subject: [PATCH 13/98] Increasing readability of Mocket core. (#258) --- mocket/mocket.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/mocket/mocket.py b/mocket/mocket.py index 570150a8..dcdab533 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -370,18 +370,15 @@ def true_sendall(self, data, *args, **kwargs): self.true_socket.connect((host, port)) self.true_socket.sendall(data, *args, **kwargs) encoded_response = b"" - # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L13 + # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 while True: - if ( - not select.select([self.true_socket], [], [], 0.1)[0] - and encoded_response - ): + more_to_read = select.select([self.true_socket], [], [], 0.1)[0] + if not more_to_read and encoded_response: break - recv = self.true_socket.recv(self._buflen) - - if not recv and encoded_response: + new_content = self.true_socket.recv(self._buflen) + if not new_content: break - encoded_response += recv + encoded_response += new_content # dump the resulting dictionary to a JSON file if Mocket.get_truesocket_recording_dir(): From 8eae0c7fc5aa99b93252e218f6244283db348ef8 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 20 Oct 2024 13:06:03 +0200 Subject: [PATCH 14/98] `aiohttp` reuses SSLContext instances created at import-time (#259) * `aiohttp` reuses SSLContext instances created at import-time. --- mocket/__init__.py | 11 +++++++++-- mocket/plugins/aiohttp_connector.py | 18 ++++++++++++++++++ tests/test_asyncio.py | 9 +++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 mocket/plugins/aiohttp_connector.py diff --git a/mocket/__init__.py b/mocket/__init__.py index 31cd56fc..8371d554 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,6 +1,13 @@ from .async_mocket import async_mocketize -from .mocket import Mocket, MocketEntry, Mocketizer, mocketize +from .mocket import FakeSSLContext, Mocket, MocketEntry, Mocketizer, mocketize -__all__ = ("async_mocketize", "mocketize", "Mocket", "MocketEntry", "Mocketizer") +__all__ = ( + "async_mocketize", + "mocketize", + "Mocket", + "MocketEntry", + "Mocketizer", + "FakeSSLContext", +) __version__ = "3.13.1" diff --git a/mocket/plugins/aiohttp_connector.py b/mocket/plugins/aiohttp_connector.py new file mode 100644 index 00000000..353c3af7 --- /dev/null +++ b/mocket/plugins/aiohttp_connector.py @@ -0,0 +1,18 @@ +import contextlib + +from mocket import FakeSSLContext + +with contextlib.suppress(ModuleNotFoundError): + from aiohttp import ClientRequest + from aiohttp.connector import TCPConnector + + class MocketTCPConnector(TCPConnector): + """ + `aiohttp` reuses SSLContext instances created at import-time, + making it more difficult for Mocket to do its job. + This is an attempt to make things smoother, at the cost of + slightly patching the `ClientSession` while testing. + """ + + def _get_ssl_context(self, req: ClientRequest) -> FakeSSLContext: + return FakeSSLContext() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 59dd474e..bef53009 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -4,10 +4,12 @@ import socket import tempfile +import aiohttp import pytest from mocket import Mocketizer, async_mocketize from mocket.mockhttp import Entry +from mocket.plugins.aiohttp_connector import MocketTCPConnector def test_asyncio_record_replay(event_loop): @@ -45,7 +47,10 @@ async def test_asyncio_connection(): @pytest.mark.asyncio @async_mocketize async def test_aiohttp(): - import aiohttp + """ + The alternative to using the custom `connector` would be importing + `aiohttp` when Mocket is already in control (inside the decorated test). + """ url = "https://bar.foo/" data = {"message": "Hello"} @@ -58,7 +63,7 @@ async def test_aiohttp(): ) async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=3) + timeout=aiohttp.ClientTimeout(total=3), connector=MocketTCPConnector() ) as session, session.get(url) as response: response = await response.json() assert response == data From 28662a7f277ec3a93abab9778ef29ec22570d6b5 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 20 Oct 2024 13:07:49 +0200 Subject: [PATCH 15/98] Bump version --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 8371d554..fb0434e9 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -10,4 +10,4 @@ "FakeSSLContext", ) -__version__ = "3.13.1" +__version__ = "3.13.2" From 8a4cc723627625c8c67769cf1b21681beb5b3239 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 20 Oct 2024 21:37:19 +0200 Subject: [PATCH 16/98] Update README.rst --- README.rst | 76 ++++++++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/README.rst b/README.rst index 17a7801c..94b5b88c 100644 --- a/README.rst +++ b/README.rst @@ -284,52 +284,44 @@ Example: .. code-block:: python - class AioHttpEntryTestCase(TestCase): - @mocketize - def test_http_session(self): - url = 'http://httpbin.org/ip' - body = "asd" * 100 - Entry.single_register(Entry.GET, url, body=body, status=404) - Entry.single_register(Entry.POST, url, body=body*2, status=201) + # `aiohttp` creates SSLContext instances at import-time + # that's why Mocket would get stuck when dealing with HTTP + # Importing the module while Mocket is in control (inside a + # decorated test function or using its context manager would + # be enough for making it work), the alternative is using a + # custom TCPConnector which always return a FakeSSLContext + # from Mocket like this example is showing. + import aiohttp + import pytest - async def main(l): - async with aiohttp.ClientSession( - loop=l, timeout=aiohttp.ClientTimeout(total=3) - ) as session: - async with session.get(url) as get_response: - assert get_response.status == 404 - assert await get_response.text() == body + from mocket import async_mocketize + from mocket.mockhttp import Entry + from mocket.plugins.aiohttp_connector import MocketTCPConnector - async with session.post(url, data=body * 6) as post_response: - assert post_response.status == 201 - assert await post_response.text() == body * 2 - loop = asyncio.new_event_loop() - loop.run_until_complete(main(loop)) + @pytest.mark.asyncio + @async_mocketize + async def test_aiohttp(): + """ + The alternative to using the custom `connector` would be importing + `aiohttp` when Mocket is already in control (inside the decorated test). + """ + + url = "https://bar.foo/" + data = {"message": "Hello"} + + Entry.single_register( + Entry.GET, + url, + body=json.dumps(data), + headers={"content-type": "application/json"}, + ) - # or again with a unittest.IsolatedAsyncioTestCase - from mocket.async_mocket import async_mocketize - - class AioHttpEntryTestCase(IsolatedAsyncioTestCase): - @async_mocketize - async def test_http_session(self): - url = 'http://httpbin.org/ip' - body = "asd" * 100 - Entry.single_register(Entry.GET, url, body=body, status=404) - Entry.single_register(Entry.POST, url, body=body * 2, status=201) - - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=3) - ) as session: - async with session.get(url) as get_response: - assert get_response.status == 404 - assert await get_response.text() == body - - async with session.post(url, data=body * 6) as post_response: - assert post_response.status == 201 - assert await post_response.text() == body * 2 - assert Mocket.last_request().method == 'POST' - assert Mocket.last_request().body == body * 6 + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=3), connector=MocketTCPConnector() + ) as session, session.get(url) as response: + response = await response.json() + assert response == data Works well with others From 643083756377aea15d63b7f99cfe421583153a70 Mon Sep 17 00:00:00 2001 From: Wilhelm Klopp Date: Sun, 3 Nov 2024 09:08:59 +0000 Subject: [PATCH 17/98] Build pure python wheel (#260) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 62c52dc7..7ba0210e 100644 --- a/Makefile +++ b/Makefile @@ -39,8 +39,8 @@ safetest: export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; make test publish: clean install-test-requirements - uv run python3 -m build --sdist . - uv run twine upload --repository mocket dist/*.tar.gz + uv run python3 -m build --sdist --wheel . + uv run twine upload --repository mocket dist/ clean: rm -rf .coverage *.egg-info dist/ requirements.txt uv.lock || true From e6c0b9ef66927287af452cdd426f0bbbd453c69a Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 10 Nov 2024 09:16:17 +0100 Subject: [PATCH 18/98] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c4b8987f..c4481efc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,7 +33,7 @@ jobs: cache: 'pip' cache-dependency-path: | pyproject.toml - - uses: hoverkraft-tech/compose-action@v2.0.0 + - uses: hoverkraft-tech/compose-action@v2.0.2 with: compose-file: "./docker-compose.yml" down-flags: "--remove-orphans" From 3bf9686ce9e55a4d43a3e6c38789a46dd04b1769 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 10 Nov 2024 21:47:00 +0100 Subject: [PATCH 19/98] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 94b5b88c..e68cbfd3 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ A socket mock framework Outside GitHub ============== -Mocket packages are available for `Arch Linux`_, `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, and of course you can **pip install** it from `PyPI`_. +Mocket packages are available for `Arch Linux`_, `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, and of course from `PyPI`_. .. _`Arch Linux`: https://archlinux.org/packages/extra/any/python-mocket/ .. _`openSUSE`: https://software.opensuse.org/search?baseproject=ALL&q=mocket From 7224addd86a6c363c170520c12a705f3e5892329 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sat, 16 Nov 2024 19:49:50 +0100 Subject: [PATCH 20/98] refactor: make imports absolute --- mocket/__init__.py | 4 ++-- mocket/async_mocket.py | 4 ++-- mocket/mocket.py | 10 ++++++++-- mocket/mockhttp.py | 4 ++-- mocket/mockredis.py | 10 ++++++++-- mocket/utils.py | 6 +++--- 6 files changed, 25 insertions(+), 13 deletions(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index fb0434e9..f72917a0 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,5 +1,5 @@ -from .async_mocket import async_mocketize -from .mocket import FakeSSLContext, Mocket, MocketEntry, Mocketizer, mocketize +from mocket.async_mocket import async_mocketize +from mocket.mocket import FakeSSLContext, Mocket, MocketEntry, Mocketizer, mocketize __all__ = ( "async_mocketize", diff --git a/mocket/async_mocket.py b/mocket/async_mocket.py index 2970e0f4..c0f77253 100644 --- a/mocket/async_mocket.py +++ b/mocket/async_mocket.py @@ -1,5 +1,5 @@ -from .mocket import Mocketizer -from .utils import get_mocketize +from mocket.mocket import Mocketizer +from mocket.utils import get_mocketize async def wrapper( diff --git a/mocket/mocket.py b/mocket/mocket.py index dcdab533..aa2e29ad 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -23,8 +23,14 @@ urllib3_wrap_socket = None -from .compat import basestring, byte_type, decode_from_bytes, encode_to_bytes, text_type -from .utils import ( +from mocket.compat import ( + basestring, + byte_type, + decode_from_bytes, + encode_to_bytes, + text_type, +) +from mocket.utils import ( MocketMode, MocketSocketCore, get_mocketize, diff --git a/mocket/mockhttp.py b/mocket/mockhttp.py index 5058328d..beb312c0 100644 --- a/mocket/mockhttp.py +++ b/mocket/mockhttp.py @@ -7,8 +7,8 @@ from h11 import SERVER, Connection, Data from h11 import Request as H11Request -from .compat import ENCODING, decode_from_bytes, do_the_magic, encode_to_bytes -from .mocket import Mocket, MocketEntry +from mocket.compat import ENCODING, decode_from_bytes, do_the_magic, encode_to_bytes +from mocket.mocket import Mocket, MocketEntry STATUS = {k: v[0] for k, v in BaseHTTPRequestHandler.responses.items()} CRLF = "\r\n" diff --git a/mocket/mockredis.py b/mocket/mockredis.py index 1a0c51e2..6ae4ef39 100644 --- a/mocket/mockredis.py +++ b/mocket/mockredis.py @@ -1,7 +1,13 @@ from itertools import chain -from .compat import byte_type, decode_from_bytes, encode_to_bytes, shsplit, text_type -from .mocket import Mocket, MocketEntry +from mocket.compat import ( + byte_type, + decode_from_bytes, + encode_to_bytes, + shsplit, + text_type, +) +from mocket.mocket import Mocket, MocketEntry class Request: diff --git a/mocket/utils.py b/mocket/utils.py index 9efd6ad9..35cfcea8 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -6,8 +6,8 @@ import ssl from typing import TYPE_CHECKING, Any, Callable, ClassVar -from .compat import decode_from_bytes, encode_to_bytes -from .exceptions import StrictMocketException +from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.exceptions import StrictMocketException if TYPE_CHECKING: # pragma: no cover from typing import NoReturn @@ -83,7 +83,7 @@ def is_allowed(self, location: str | tuple[str, int]) -> bool: @staticmethod def raise_not_allowed() -> NoReturn: - from .mocket import Mocket + from mocket.mocket import Mocket current_entries = [ (location, "\n ".join(map(str, entries))) From 1cf09ec3a64c7e9b1952ef9fb9f788177dcf24b0 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 13:41:15 +0100 Subject: [PATCH 21/98] refactor: remove old compat text_type, byte_type, basestring --- mocket/compat.py | 12 ++++-------- mocket/mocket.py | 26 +++++++++----------------- mocket/mockredis.py | 12 +++++------- mocket/plugins/httpretty/__init__.py | 6 +++--- 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/mocket/compat.py b/mocket/compat.py index 276ae0f0..1ac2fc89 100644 --- a/mocket/compat.py +++ b/mocket/compat.py @@ -9,21 +9,17 @@ ENCODING: Final[str] = os.getenv("MOCKET_ENCODING", "utf-8") -text_type = str -byte_type = bytes -basestring = (str,) - def encode_to_bytes(s: str | bytes, encoding: str = ENCODING) -> bytes: - if isinstance(s, text_type): + if isinstance(s, str): s = s.encode(encoding) - return byte_type(s) + return bytes(s) def decode_from_bytes(s: str | bytes, encoding: str = ENCODING) -> str: - if isinstance(s, byte_type): + if isinstance(s, bytes): s = codecs.decode(s, encoding, "ignore") - return text_type(s) + return str(s) def shsplit(s: str | bytes) -> list[str]: diff --git a/mocket/mocket.py b/mocket/mocket.py index aa2e29ad..81a42bfb 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -23,13 +23,7 @@ urllib3_wrap_socket = None -from mocket.compat import ( - basestring, - byte_type, - decode_from_bytes, - encode_to_bytes, - text_type, -) +from mocket.compat import decode_from_bytes, encode_to_bytes from mocket.utils import ( MocketMode, MocketSocketCore, @@ -323,7 +317,7 @@ def true_sendall(self, data, *args, **kwargs): # make request unique again req_signature = _hash_request(hasher, req) # port should be always a string - port = text_type(self._port) + port = str(self._port) # prepare responses dictionary responses = {} @@ -433,7 +427,7 @@ class Mocket: _address = (None, None) _entries = collections.defaultdict(list) _requests = [] - _namespace = text_type(id(_entries)) + _namespace = str(id(_entries)) _truesocket_recording_dir = None @classmethod @@ -524,7 +518,7 @@ def enable(namespace=None, truesocket_recording_dir=None): socket.socketpair = socket.__dict__["socketpair"] = socketpair ssl.wrap_socket = ssl.__dict__["wrap_socket"] = FakeSSLContext.wrap_socket ssl.SSLContext = ssl.__dict__["SSLContext"] = FakeSSLContext - socket.inet_pton = socket.__dict__["inet_pton"] = lambda family, ip: byte_type( + socket.inet_pton = socket.__dict__["inet_pton"] = lambda family, ip: bytes( "\x7f\x00\x00\x01", "utf-8" ) urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( @@ -598,13 +592,13 @@ def assert_fail_if_entries_not_served(cls): class MocketEntry: - class Response(byte_type): + class Response(bytes): @property def data(self): return self response_index = 0 - request_cls = byte_type + request_cls = bytes response_cls = Response responses = None _served = None @@ -613,9 +607,7 @@ def __init__(self, location, responses): self._served = False self.location = location - if not isinstance(responses, collections_abc.Iterable) or isinstance( - responses, basestring - ): + if not isinstance(responses, collections_abc.Iterable): responses = [responses] if not responses: @@ -624,7 +616,7 @@ def __init__(self, location, responses): self.responses = [] for r in responses: if not isinstance(r, BaseException) and not getattr(r, "data", False): - if isinstance(r, text_type): + if isinstance(r, str): r = encode_to_bytes(r) r = self.response_cls(r) self.responses.append(r) @@ -664,7 +656,7 @@ def __init__( ): self.instance = instance self.truesocket_recording_dir = truesocket_recording_dir - self.namespace = namespace or text_type(id(self)) + self.namespace = namespace or str(id(self)) MocketMode().STRICT = strict_mode if strict_mode: MocketMode().STRICT_ALLOWED = strict_mode_allowed or [] diff --git a/mocket/mockredis.py b/mocket/mockredis.py index 6ae4ef39..4ed69e1f 100644 --- a/mocket/mockredis.py +++ b/mocket/mockredis.py @@ -1,11 +1,9 @@ from itertools import chain from mocket.compat import ( - byte_type, decode_from_bytes, encode_to_bytes, shsplit, - text_type, ) from mocket.mocket import Mocket, MocketEntry @@ -20,7 +18,7 @@ def __init__(self, data=None): self.data = Redisizer.redisize(data or OK) -class Redisizer(byte_type): +class Redisizer(bytes): @staticmethod def tokens(iterable): iterable = [encode_to_bytes(x) for x in iterable] @@ -36,15 +34,15 @@ def get_conversion(t): Redisizer.tokens(list(chain(*tuple(x.items())))) ), int: lambda x: f":{x}".encode(), - text_type: lambda x: "${}\r\n{}".format( - len(x.encode("utf-8")), x - ).encode("utf-8"), + str: lambda x: "${}\r\n{}".format(len(x.encode("utf-8")), x).encode( + "utf-8" + ), list: lambda x: b"\r\n".join(Redisizer.tokens(x)), }[t] if isinstance(data, Redisizer): return data - if isinstance(data, byte_type): + if isinstance(data, bytes): data = decode_from_bytes(data) return Redisizer(get_conversion(data.__class__)(data) + b"\r\n") diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index 9d61ae2e..d5e41e30 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -1,6 +1,6 @@ from mocket import Mocket, mocketize from mocket.async_mocket import async_mocketize -from mocket.compat import ENCODING, byte_type, text_type +from mocket.compat import ENCODING from mocket.mockhttp import Entry as MocketHttpEntry from mocket.mockhttp import Request as MocketHttpRequest from mocket.mockhttp import Response as MocketHttpResponse @@ -129,6 +129,6 @@ def __getattr__(self, name): "HEAD", "PATCH", "register_uri", - "text_type", - "byte_type", + "str", + "bytes", ) From dccdd3bb30947c484ab9a0cda520a71186a9e453 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 15:36:11 +0100 Subject: [PATCH 22/98] refactor: move MocketMode from mocket.utils to mocket.mode --- mocket/mocket.py | 2 +- mocket/mode.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ mocket/utils.py | 46 +++------------------------------------------- 3 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 mocket/mode.py diff --git a/mocket/mocket.py b/mocket/mocket.py index 81a42bfb..3918de14 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -24,8 +24,8 @@ from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.mode import MocketMode from mocket.utils import ( - MocketMode, MocketSocketCore, get_mocketize, hexdump, diff --git a/mocket/mode.py b/mocket/mode.py new file mode 100644 index 00000000..3c0638e5 --- /dev/null +++ b/mocket/mode.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from mocket.exceptions import StrictMocketException + +if TYPE_CHECKING: # pragma: no cover + from typing import NoReturn + + +class MocketMode: + __shared_state: ClassVar[dict[str, Any]] = {} + STRICT: ClassVar = None + STRICT_ALLOWED: ClassVar = None + + def __init__(self) -> None: + self.__dict__ = self.__shared_state + + def is_allowed(self, location: str | tuple[str, int]) -> bool: + """ + Checks if (`host`, `port`) or at least `host` + are allowed locations to perform real `socket` calls + """ + if not self.STRICT: + return True + + host_allowed = False + if isinstance(location, tuple): + host_allowed = location[0] in self.STRICT_ALLOWED + return host_allowed or location in self.STRICT_ALLOWED + + @staticmethod + def raise_not_allowed() -> NoReturn: + from mocket.mocket import Mocket + + current_entries = [ + (location, "\n ".join(map(str, entries))) + for location, entries in Mocket._entries.items() + ] + formatted_entries = "\n".join( + [f" {location}:\n {entries}" for location, entries in current_entries] + ) + raise StrictMocketException( + "Mocket tried to use the real `socket` module while STRICT mode was active.\n" + f"Registered entries:\n{formatted_entries}" + ) diff --git a/mocket/utils.py b/mocket/utils.py index 35cfcea8..5f065bfa 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -4,14 +4,12 @@ import io import os import ssl -from typing import TYPE_CHECKING, Any, Callable, ClassVar +from typing import Callable from mocket.compat import decode_from_bytes, encode_to_bytes -from mocket.exceptions import StrictMocketException - -if TYPE_CHECKING: # pragma: no cover - from typing import NoReturn +# NOTE this is here for backwards-compat to keep old import-paths working +from mocket.mode import MocketMode as MocketMode SSL_PROTOCOL = ssl.PROTOCOL_TLSv1_2 @@ -58,41 +56,3 @@ def get_mocketize(wrapper_: Callable) -> Callable: wrapper_, kwsyntax=True, ) - - -class MocketMode: - __shared_state: ClassVar[dict[str, Any]] = {} - STRICT: ClassVar = None - STRICT_ALLOWED: ClassVar = None - - def __init__(self) -> None: - self.__dict__ = self.__shared_state - - def is_allowed(self, location: str | tuple[str, int]) -> bool: - """ - Checks if (`host`, `port`) or at least `host` - are allowed locations to perform real `socket` calls - """ - if not self.STRICT: - return True - - host_allowed = False - if isinstance(location, tuple): - host_allowed = location[0] in self.STRICT_ALLOWED - return host_allowed or location in self.STRICT_ALLOWED - - @staticmethod - def raise_not_allowed() -> NoReturn: - from mocket.mocket import Mocket - - current_entries = [ - (location, "\n ".join(map(str, entries))) - for location, entries in Mocket._entries.items() - ] - formatted_entries = "\n".join( - [f" {location}:\n {entries}" for location, entries in current_entries] - ) - raise StrictMocketException( - "Mocket tried to use the real `socket` module while STRICT mode was active.\n" - f"Registered entries:\n{formatted_entries}" - ) From 2e9b640564f1db78caffad91b223a324d22bfd5d Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 15:46:13 +0100 Subject: [PATCH 23/98] refactor: move Mocketizer and mocketize from mocket.mocket to mocket.mocketizer --- mocket/__init__.py | 3 +- mocket/async_mocket.py | 2 +- mocket/mocket.py | 92 --------------------------------------- mocket/mocketizer.py | 97 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 94 deletions(-) create mode 100644 mocket/mocketizer.py diff --git a/mocket/__init__.py b/mocket/__init__.py index f72917a0..b8f1e032 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,5 +1,6 @@ from mocket.async_mocket import async_mocketize -from mocket.mocket import FakeSSLContext, Mocket, MocketEntry, Mocketizer, mocketize +from mocket.mocket import FakeSSLContext, Mocket, MocketEntry +from mocket.mocketizer import Mocketizer, mocketize __all__ = ( "async_mocketize", diff --git a/mocket/async_mocket.py b/mocket/async_mocket.py index c0f77253..709d225f 100644 --- a/mocket/async_mocket.py +++ b/mocket/async_mocket.py @@ -1,4 +1,4 @@ -from mocket.mocket import Mocketizer +from mocket.mocketizer import Mocketizer from mocket.utils import get_mocketize diff --git a/mocket/mocket.py b/mocket/mocket.py index 3918de14..f420c27d 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -27,7 +27,6 @@ from mocket.mode import MocketMode from mocket.utils import ( MocketSocketCore, - get_mocketize, hexdump, hexload, ) @@ -643,94 +642,3 @@ def get_response(self): raise response return response.data - - -class Mocketizer: - def __init__( - self, - instance=None, - namespace=None, - truesocket_recording_dir=None, - strict_mode=False, - strict_mode_allowed=None, - ): - self.instance = instance - self.truesocket_recording_dir = truesocket_recording_dir - self.namespace = namespace or str(id(self)) - MocketMode().STRICT = strict_mode - if strict_mode: - MocketMode().STRICT_ALLOWED = strict_mode_allowed or [] - elif strict_mode_allowed: - raise ValueError( - "Allowed locations are only accepted when STRICT mode is active." - ) - - def enter(self): - Mocket.enable( - namespace=self.namespace, - truesocket_recording_dir=self.truesocket_recording_dir, - ) - if self.instance: - self.check_and_call("mocketize_setup") - - def __enter__(self): - self.enter() - return self - - def exit(self): - if self.instance: - self.check_and_call("mocketize_teardown") - Mocket.disable() - - def __exit__(self, type, value, tb): - self.exit() - - async def __aenter__(self, *args, **kwargs): - self.enter() - return self - - async def __aexit__(self, *args, **kwargs): - self.exit() - - def check_and_call(self, method_name): - method = getattr(self.instance, method_name, None) - if callable(method): - method() - - @staticmethod - def factory(test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args): - instance = args[0] if args else None - namespace = None - if truesocket_recording_dir: - namespace = ".".join( - ( - instance.__class__.__module__, - instance.__class__.__name__, - test.__name__, - ) - ) - - return Mocketizer( - instance, - namespace=namespace, - truesocket_recording_dir=truesocket_recording_dir, - strict_mode=strict_mode, - strict_mode_allowed=strict_mode_allowed, - ) - - -def wrapper( - test, - truesocket_recording_dir=None, - strict_mode=False, - strict_mode_allowed=None, - *args, - **kwargs, -): - with Mocketizer.factory( - test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args - ): - return test(*args, **kwargs) - - -mocketize = get_mocketize(wrapper_=wrapper) diff --git a/mocket/mocketizer.py b/mocket/mocketizer.py new file mode 100644 index 00000000..5a988c77 --- /dev/null +++ b/mocket/mocketizer.py @@ -0,0 +1,97 @@ +from mocket.mode import MocketMode +from mocket.utils import get_mocketize + + +class Mocketizer: + def __init__( + self, + instance=None, + namespace=None, + truesocket_recording_dir=None, + strict_mode=False, + strict_mode_allowed=None, + ): + self.instance = instance + self.truesocket_recording_dir = truesocket_recording_dir + self.namespace = namespace or str(id(self)) + MocketMode().STRICT = strict_mode + if strict_mode: + MocketMode().STRICT_ALLOWED = strict_mode_allowed or [] + elif strict_mode_allowed: + raise ValueError( + "Allowed locations are only accepted when STRICT mode is active." + ) + + def enter(self): + from mocket import Mocket + + Mocket.enable( + namespace=self.namespace, + truesocket_recording_dir=self.truesocket_recording_dir, + ) + if self.instance: + self.check_and_call("mocketize_setup") + + def __enter__(self): + self.enter() + return self + + def exit(self): + if self.instance: + self.check_and_call("mocketize_teardown") + from mocket import Mocket + + Mocket.disable() + + def __exit__(self, type, value, tb): + self.exit() + + async def __aenter__(self, *args, **kwargs): + self.enter() + return self + + async def __aexit__(self, *args, **kwargs): + self.exit() + + def check_and_call(self, method_name): + method = getattr(self.instance, method_name, None) + if callable(method): + method() + + @staticmethod + def factory(test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args): + instance = args[0] if args else None + namespace = None + if truesocket_recording_dir: + namespace = ".".join( + ( + instance.__class__.__module__, + instance.__class__.__name__, + test.__name__, + ) + ) + + return Mocketizer( + instance, + namespace=namespace, + truesocket_recording_dir=truesocket_recording_dir, + strict_mode=strict_mode, + strict_mode_allowed=strict_mode_allowed, + ) + + +def wrapper( + test, + truesocket_recording_dir=None, + strict_mode=False, + strict_mode_allowed=None, + *args, + **kwargs, +): + with Mocketizer.factory( + test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args + ): + return test(*args, **kwargs) + + +mocketize = get_mocketize(wrapper_=wrapper) From 1df405cb0901197093e35d8d5e109e95459f3c8d Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 16:04:28 +0100 Subject: [PATCH 24/98] refactor: move MocketEntry from mocket.mocket to mocket.entry --- mocket/__init__.py | 3 ++- mocket/entry.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ mocket/mocket.py | 55 ------------------------------------------ mocket/mockhttp.py | 3 ++- mocket/mockredis.py | 3 ++- 5 files changed, 65 insertions(+), 58 deletions(-) create mode 100644 mocket/entry.py diff --git a/mocket/__init__.py b/mocket/__init__.py index b8f1e032..30ec55a7 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,5 +1,6 @@ from mocket.async_mocket import async_mocketize -from mocket.mocket import FakeSSLContext, Mocket, MocketEntry +from mocket.entry import MocketEntry +from mocket.mocket import FakeSSLContext, Mocket from mocket.mocketizer import Mocketizer, mocketize __all__ = ( diff --git a/mocket/entry.py b/mocket/entry.py new file mode 100644 index 00000000..8fa28bc7 --- /dev/null +++ b/mocket/entry.py @@ -0,0 +1,59 @@ +import collections.abc + +from mocket.compat import encode_to_bytes + + +class MocketEntry: + class Response(bytes): + @property + def data(self): + return self + + response_index = 0 + request_cls = bytes + response_cls = Response + responses = None + _served = None + + def __init__(self, location, responses): + self._served = False + self.location = location + + if not isinstance(responses, collections.abc.Iterable): + responses = [responses] + + if not responses: + self.responses = [self.response_cls(encode_to_bytes(""))] + else: + self.responses = [] + for r in responses: + if not isinstance(r, BaseException) and not getattr(r, "data", False): + if isinstance(r, str): + r = encode_to_bytes(r) + r = self.response_cls(r) + self.responses.append(r) + + def __repr__(self): + return f"{self.__class__.__name__}(location={self.location})" + + @staticmethod + def can_handle(data): + return True + + def collect(self, data): + from mocket import Mocket + + req = self.request_cls(data) + Mocket.collect(req) + + def get_response(self): + response = self.responses[self.response_index] + if self.response_index < len(self.responses) - 1: + self.response_index += 1 + + self._served = True + + if isinstance(response, BaseException): + raise response + + return response.data diff --git a/mocket/mocket.py b/mocket/mocket.py index f420c27d..e9bb27e3 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -1,5 +1,4 @@ import collections -import collections.abc as collections_abc import contextlib import errno import hashlib @@ -588,57 +587,3 @@ def assert_fail_if_entries_not_served(cls): """Mocket checks that all entries have been served at least once.""" if not all(entry._served for entry in itertools.chain(*cls._entries.values())): raise AssertionError("Some Mocket entries have not been served") - - -class MocketEntry: - class Response(bytes): - @property - def data(self): - return self - - response_index = 0 - request_cls = bytes - response_cls = Response - responses = None - _served = None - - def __init__(self, location, responses): - self._served = False - self.location = location - - if not isinstance(responses, collections_abc.Iterable): - responses = [responses] - - if not responses: - self.responses = [self.response_cls(encode_to_bytes(""))] - else: - self.responses = [] - for r in responses: - if not isinstance(r, BaseException) and not getattr(r, "data", False): - if isinstance(r, str): - r = encode_to_bytes(r) - r = self.response_cls(r) - self.responses.append(r) - - def __repr__(self): - return f"{self.__class__.__name__}(location={self.location})" - - @staticmethod - def can_handle(data): - return True - - def collect(self, data): - req = self.request_cls(data) - Mocket.collect(req) - - def get_response(self): - response = self.responses[self.response_index] - if self.response_index < len(self.responses) - 1: - self.response_index += 1 - - self._served = True - - if isinstance(response, BaseException): - raise response - - return response.data diff --git a/mocket/mockhttp.py b/mocket/mockhttp.py index beb312c0..245a11af 100644 --- a/mocket/mockhttp.py +++ b/mocket/mockhttp.py @@ -8,7 +8,8 @@ from h11 import Request as H11Request from mocket.compat import ENCODING, decode_from_bytes, do_the_magic, encode_to_bytes -from mocket.mocket import Mocket, MocketEntry +from mocket.entry import MocketEntry +from mocket.mocket import Mocket STATUS = {k: v[0] for k, v in BaseHTTPRequestHandler.responses.items()} CRLF = "\r\n" diff --git a/mocket/mockredis.py b/mocket/mockredis.py index 4ed69e1f..fc386e2d 100644 --- a/mocket/mockredis.py +++ b/mocket/mockredis.py @@ -5,7 +5,8 @@ encode_to_bytes, shsplit, ) -from mocket.mocket import Mocket, MocketEntry +from mocket.entry import MocketEntry +from mocket.mocket import Mocket class Request: From 207778acd16368012eaced3b65eb4324477f5b1b Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 16:12:03 +0100 Subject: [PATCH 25/98] refactor: move SocketMocketCore from mocket.utils to mocket.io --- mocket/io.py | 17 +++++++++++++++++ mocket/mocket.py | 7 ++----- mocket/utils.py | 20 +++----------------- 3 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 mocket/io.py diff --git a/mocket/io.py b/mocket/io.py new file mode 100644 index 00000000..45bb8272 --- /dev/null +++ b/mocket/io.py @@ -0,0 +1,17 @@ +import io +import os + + +class MocketSocketCore(io.BytesIO): + def __init__(self, address) -> None: + self._address = address + super().__init__() + + def write(self, content): + from mocket import Mocket + + super().write(content) + + _, w_fd = Mocket.get_pair(self._address) + if w_fd: + os.write(w_fd, content) diff --git a/mocket/mocket.py b/mocket/mocket.py index e9bb27e3..fb7ec8a0 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -23,12 +23,9 @@ from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.io import MocketSocketCore from mocket.mode import MocketMode -from mocket.utils import ( - MocketSocketCore, - hexdump, - hexload, -) +from mocket.utils import hexdump, hexload xxh32 = None try: diff --git a/mocket/utils.py b/mocket/utils.py index 5f065bfa..f94b78f7 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -1,34 +1,20 @@ from __future__ import annotations import binascii -import io -import os import ssl from typing import Callable from mocket.compat import decode_from_bytes, encode_to_bytes +# NOTE this is here for backwards-compat to keep old import-paths working +from mocket.io import MocketSocketCore as MocketSocketCore + # NOTE this is here for backwards-compat to keep old import-paths working from mocket.mode import MocketMode as MocketMode SSL_PROTOCOL = ssl.PROTOCOL_TLSv1_2 -class MocketSocketCore(io.BytesIO): - def __init__(self, address) -> None: - self._address = address - super().__init__() - - def write(self, content): - from mocket import Mocket - - super().write(content) - - _, w_fd = Mocket.get_pair(self._address) - if w_fd: - os.write(w_fd, content) - - def hexdump(binary_string: bytes) -> str: r""" >>> hexdump(b"bar foobar foo") == decode_from_bytes(encode_to_bytes("62 61 72 20 66 6F 6F 62 61 72 20 66 6F 6F")) From 012df1387282d74b7bf286c9de7583e9b1ff8e25 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 16:15:17 +0100 Subject: [PATCH 26/98] refactor: move FakeSSLContext from mocket.mocket to mocket.ssl --- mocket/__init__.py | 3 ++- mocket/mocket.py | 57 +--------------------------------------------- mocket/ssl.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 57 deletions(-) create mode 100644 mocket/ssl.py diff --git a/mocket/__init__.py b/mocket/__init__.py index 30ec55a7..d64cb11d 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,7 +1,8 @@ from mocket.async_mocket import async_mocketize from mocket.entry import MocketEntry -from mocket.mocket import FakeSSLContext, Mocket +from mocket.mocket import Mocket from mocket.mocketizer import Mocketizer, mocketize +from mocket.ssl import FakeSSLContext __all__ = ( "async_mocketize", diff --git a/mocket/mocket.py b/mocket/mocket.py index fb7ec8a0..8f791ea3 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -25,6 +25,7 @@ from mocket.compat import decode_from_bytes, encode_to_bytes from mocket.io import MocketSocketCore from mocket.mode import MocketMode +from mocket.ssl import FakeSSLContext from mocket.utils import hexdump, hexload xxh32 = None @@ -59,62 +60,6 @@ true_urllib3_match_hostname = urllib3_match_hostname -class SuperFakeSSLContext: - """For Python 3.6 and newer.""" - - class FakeSetter(int): - def __set__(self, *args): - pass - - minimum_version = FakeSetter() - options = FakeSetter() - verify_mode = FakeSetter() - verify_flags = FakeSetter() - - -class FakeSSLContext(SuperFakeSSLContext): - DUMMY_METHODS = ( - "load_default_certs", - "load_verify_locations", - "set_alpn_protocols", - "set_ciphers", - "set_default_verify_paths", - ) - sock = None - post_handshake_auth = None - _check_hostname = False - - @property - def check_hostname(self): - return self._check_hostname - - @check_hostname.setter - def check_hostname(self, _): - self._check_hostname = False - - def __init__(self, *args, **kwargs): - self._set_dummy_methods() - - def _set_dummy_methods(self): - def dummy_method(*args, **kwargs): - pass - - for m in self.DUMMY_METHODS: - setattr(self, m, dummy_method) - - @staticmethod - def wrap_socket(sock, *args, **kwargs): - sock.kwargs = kwargs - sock._secure_socket = True - return sock - - @staticmethod - def wrap_bio(incoming, outcoming, *args, **kwargs): - ssl_obj = MocketSocket() - ssl_obj._host = kwargs["server_hostname"] - return ssl_obj - - def create_connection(address, timeout=None, source_address=None): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) if timeout: diff --git a/mocket/ssl.py b/mocket/ssl.py new file mode 100644 index 00000000..2e367f16 --- /dev/null +++ b/mocket/ssl.py @@ -0,0 +1,56 @@ +class SuperFakeSSLContext: + """For Python 3.6 and newer.""" + + class FakeSetter(int): + def __set__(self, *args): + pass + + minimum_version = FakeSetter() + options = FakeSetter() + verify_mode = FakeSetter() + verify_flags = FakeSetter() + + +class FakeSSLContext(SuperFakeSSLContext): + DUMMY_METHODS = ( + "load_default_certs", + "load_verify_locations", + "set_alpn_protocols", + "set_ciphers", + "set_default_verify_paths", + ) + sock = None + post_handshake_auth = None + _check_hostname = False + + @property + def check_hostname(self): + return self._check_hostname + + @check_hostname.setter + def check_hostname(self, _): + self._check_hostname = False + + def __init__(self, *args, **kwargs): + self._set_dummy_methods() + + def _set_dummy_methods(self): + def dummy_method(*args, **kwargs): + pass + + for m in self.DUMMY_METHODS: + setattr(self, m, dummy_method) + + @staticmethod + def wrap_socket(sock, *args, **kwargs): + sock.kwargs = kwargs + sock._secure_socket = True + return sock + + @staticmethod + def wrap_bio(incoming, outcoming, *args, **kwargs): + from mocket.mocket import MocketSocket + + ssl_obj = MocketSocket() + ssl_obj._host = kwargs["server_hostname"] + return ssl_obj From 14513def5d44e47c845618c5b4f39ba77fd1174a Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 16:25:15 +0100 Subject: [PATCH 27/98] refactor: move MocketSocket from mocket.mocket to mocket.socket --- mocket/mocket.py | 322 +------------------------------------------ mocket/socket.py | 346 +++++++++++++++++++++++++++++++++++++++++++++++ mocket/ssl.py | 2 +- 3 files changed, 348 insertions(+), 322 deletions(-) create mode 100644 mocket/socket.py diff --git a/mocket/mocket.py b/mocket/mocket.py index 8f791ea3..6bb0e566 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -1,15 +1,8 @@ import collections -import contextlib -import errno -import hashlib import itertools -import json import os -import select import socket import ssl -from datetime import datetime, timedelta -from json.decoder import JSONDecodeError from typing import Optional, Tuple import urllib3 @@ -22,19 +15,8 @@ urllib3_wrap_socket = None -from mocket.compat import decode_from_bytes, encode_to_bytes -from mocket.io import MocketSocketCore -from mocket.mode import MocketMode +from mocket.socket import MocketSocket, create_connection, socketpair from mocket.ssl import FakeSSLContext -from mocket.utils import hexdump, hexload - -xxh32 = None -try: - from xxhash import xxh32 -except ImportError: # pragma: no cover - with contextlib.suppress(ImportError): - from xxhash_cffi import xxh32 -hasher = xxh32 or hashlib.md5 try: # pragma: no cover from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 @@ -60,308 +42,6 @@ true_urllib3_match_hostname = urllib3_match_hostname -def create_connection(address, timeout=None, source_address=None): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) - if timeout: - s.settimeout(timeout) - s.connect(address) - return s - - -def socketpair(*args, **kwargs): - """Returns a real socketpair() used by asyncio loop for supporting calls made by fastapi and similar services.""" - import _socket - - return _socket.socketpair(*args, **kwargs) - - -def _hash_request(h, req): - return h(encode_to_bytes("".join(sorted(req.split("\r\n"))))).hexdigest() - - -class MocketSocket: - timeout = None - _fd = None - family = None - type = None - proto = None - _host = None - _port = None - _address = None - cipher = lambda s: ("ADH", "AES256", "SHA") - compression = lambda s: ssl.OP_NO_COMPRESSION - _mode = None - _bufsize = None - _secure_socket = False - _did_handshake = False - _sent_non_empty_bytes = False - _io = None - - def __init__( - self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, **kwargs - ): - self.true_socket = true_socket(family, type, proto) - self._buflen = 65536 - self._entry = None - self.family = int(family) - self.type = int(type) - self.proto = int(proto) - self._truesocket_recording_dir = None - self.kwargs = kwargs - - def __str__(self): - return f"({self.__class__.__name__})(family={self.family} type={self.type} protocol={self.proto})" - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - @property - def io(self): - if self._io is None: - self._io = MocketSocketCore((self._host, self._port)) - return self._io - - def fileno(self): - address = (self._host, self._port) - r_fd, _ = Mocket.get_pair(address) - if not r_fd: - r_fd, w_fd = os.pipe() - Mocket.set_pair(address, (r_fd, w_fd)) - return r_fd - - def gettimeout(self): - return self.timeout - - def setsockopt(self, family, type, proto): - self.family = family - self.type = type - self.proto = proto - - if self.true_socket: - self.true_socket.setsockopt(family, type, proto) - - def settimeout(self, timeout): - self.timeout = timeout - - @staticmethod - def getsockopt(level, optname, buflen=None): - return socket.SOCK_STREAM - - def do_handshake(self): - self._did_handshake = True - - def getpeername(self): - return self._address - - def setblocking(self, block): - self.settimeout(None) if block else self.settimeout(0.0) - - def getblocking(self): - return self.gettimeout() is None - - def getsockname(self): - return socket.gethostbyname(self._address[0]), self._address[1] - - def getpeercert(self, *args, **kwargs): - if not (self._host and self._port): - self._address = self._host, self._port = Mocket._address - - now = datetime.now() - shift = now + timedelta(days=30 * 12) - return { - "notAfter": shift.strftime("%b %d %H:%M:%S GMT"), - "subjectAltName": ( - ("DNS", f"*.{self._host}"), - ("DNS", self._host), - ("DNS", "*"), - ), - "subject": ( - (("organizationName", f"*.{self._host}"),), - (("organizationalUnitName", "Domain Control Validated"),), - (("commonName", f"*.{self._host}"),), - ), - } - - def unwrap(self): - return self - - def write(self, data): - return self.send(encode_to_bytes(data)) - - def connect(self, address): - self._address = self._host, self._port = address - Mocket._address = address - - def makefile(self, mode="r", bufsize=-1): - self._mode = mode - self._bufsize = bufsize - return self.io - - def get_entry(self, data): - return Mocket.get_entry(self._host, self._port, data) - - def sendall(self, data, entry=None, *args, **kwargs): - if entry is None: - entry = self.get_entry(data) - - if entry: - consume_response = entry.collect(data) - response = entry.get_response() if consume_response is not False else None - else: - response = self.true_sendall(data, *args, **kwargs) - - if response is not None: - self.io.seek(0) - self.io.write(response) - self.io.truncate() - self.io.seek(0) - - def read(self, buffersize): - rv = self.io.read(buffersize) - if rv: - self._sent_non_empty_bytes = True - if self._did_handshake and not self._sent_non_empty_bytes: - raise ssl.SSLWantReadError("The operation did not complete (read)") - return rv - - def recv_into(self, buffer, buffersize=None, flags=None): - if hasattr(buffer, "write"): - return buffer.write(self.read(buffersize)) - # buffer is a memoryview - data = self.read(buffersize) - if data: - buffer[: len(data)] = data - return len(data) - - def recv(self, buffersize, flags=None): - r_fd, _ = Mocket.get_pair((self._host, self._port)) - if r_fd: - return os.read(r_fd, buffersize) - data = self.read(buffersize) - if data: - return data - # used by Redis mock - exc = BlockingIOError() - exc.errno = errno.EWOULDBLOCK - exc.args = (0,) - raise exc - - def true_sendall(self, data, *args, **kwargs): - if not MocketMode().is_allowed((self._host, self._port)): - MocketMode.raise_not_allowed() - - req = decode_from_bytes(data) - # make request unique again - req_signature = _hash_request(hasher, req) - # port should be always a string - port = str(self._port) - - # prepare responses dictionary - responses = {} - - if Mocket.get_truesocket_recording_dir(): - path = os.path.join( - Mocket.get_truesocket_recording_dir(), Mocket.get_namespace() + ".json" - ) - # check if there's already a recorded session dumped to a JSON file - try: - with open(path) as f: - responses = json.load(f) - # if not, create a new dictionary - except (FileNotFoundError, JSONDecodeError): - pass - - try: - try: - response_dict = responses[self._host][port][req_signature] - except KeyError: - if hasher is not hashlib.md5: - # Fallback for backwards compatibility - req_signature = _hash_request(hashlib.md5, req) - response_dict = responses[self._host][port][req_signature] - else: - raise - except KeyError: - # preventing next KeyError exceptions - responses.setdefault(self._host, {}) - responses[self._host].setdefault(port, {}) - responses[self._host][port].setdefault(req_signature, {}) - response_dict = responses[self._host][port][req_signature] - - # try to get the response from the dictionary - try: - encoded_response = hexload(response_dict["response"]) - # if not available, call the real sendall - except KeyError: - host, port = self._host, self._port - host = true_gethostbyname(host) - - if isinstance(self.true_socket, true_socket) and self._secure_socket: - self.true_socket = true_urllib3_ssl_wrap_socket( - self.true_socket, - **self.kwargs, - ) - - with contextlib.suppress(OSError, ValueError): - # already connected - self.true_socket.connect((host, port)) - self.true_socket.sendall(data, *args, **kwargs) - encoded_response = b"" - # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 - while True: - more_to_read = select.select([self.true_socket], [], [], 0.1)[0] - if not more_to_read and encoded_response: - break - new_content = self.true_socket.recv(self._buflen) - if not new_content: - break - encoded_response += new_content - - # dump the resulting dictionary to a JSON file - if Mocket.get_truesocket_recording_dir(): - # update the dictionary with request and response lines - response_dict["request"] = req - response_dict["response"] = hexdump(encoded_response) - - with open(path, mode="w") as f: - f.write( - decode_from_bytes( - json.dumps(responses, indent=4, sort_keys=True) - ) - ) - - # response back to .sendall() which writes it to the Mocket socket and flush the BytesIO - return encoded_response - - def send(self, data, *args, **kwargs): # pragma: no cover - entry = self.get_entry(data) - if not entry or (entry and self._entry != entry): - kwargs["entry"] = entry - self.sendall(data, *args, **kwargs) - else: - req = Mocket.last_request() - if hasattr(req, "add_data"): - req.add_data(data) - self._entry = entry - return len(data) - - def close(self): - if self.true_socket and not self.true_socket._closed: - self.true_socket.close() - self._fd = None - - def __getattr__(self, name): - """Do nothing catchall function, for methods like shutdown()""" - - def do_nothing(*args, **kwargs): - pass - - return do_nothing - - class Mocket: _socket_pairs = {} _address = (None, None) diff --git a/mocket/socket.py b/mocket/socket.py new file mode 100644 index 00000000..3a971af5 --- /dev/null +++ b/mocket/socket.py @@ -0,0 +1,346 @@ +import contextlib +import errno +import hashlib +import json +import os +import select +import socket +import ssl +from datetime import datetime, timedelta +from json.decoder import JSONDecodeError + +from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.io import MocketSocketCore +from mocket.mode import MocketMode +from mocket.utils import hexdump, hexload + +xxh32 = None +try: + from xxhash import xxh32 +except ImportError: # pragma: no cover + with contextlib.suppress(ImportError): + from xxhash_cffi import xxh32 +hasher = xxh32 or hashlib.md5 + + +def create_connection(address, timeout=None, source_address=None): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) + if timeout: + s.settimeout(timeout) + s.connect(address) + return s + + +def socketpair(*args, **kwargs): + """Returns a real socketpair() used by asyncio loop for supporting calls made by fastapi and similar services.""" + import _socket + + return _socket.socketpair(*args, **kwargs) + + +def _hash_request(h, req): + return h(encode_to_bytes("".join(sorted(req.split("\r\n"))))).hexdigest() + + +class MocketSocket: + timeout = None + _fd = None + family = None + type = None + proto = None + _host = None + _port = None + _address = None + cipher = lambda s: ("ADH", "AES256", "SHA") + compression = lambda s: ssl.OP_NO_COMPRESSION + _mode = None + _bufsize = None + _secure_socket = False + _did_handshake = False + _sent_non_empty_bytes = False + _io = None + + def __init__( + self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, **kwargs + ): + from mocket.mocket import true_socket + + self.true_socket = true_socket(family, type, proto) + self._buflen = 65536 + self._entry = None + self.family = int(family) + self.type = int(type) + self.proto = int(proto) + self._truesocket_recording_dir = None + self.kwargs = kwargs + + def __str__(self): + return f"({self.__class__.__name__})(family={self.family} type={self.type} protocol={self.proto})" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + @property + def io(self): + if self._io is None: + self._io = MocketSocketCore((self._host, self._port)) + return self._io + + def fileno(self): + from mocket.mocket import Mocket + + address = (self._host, self._port) + r_fd, _ = Mocket.get_pair(address) + if not r_fd: + r_fd, w_fd = os.pipe() + Mocket.set_pair(address, (r_fd, w_fd)) + return r_fd + + def gettimeout(self): + return self.timeout + + def setsockopt(self, family, type, proto): + self.family = family + self.type = type + self.proto = proto + + if self.true_socket: + self.true_socket.setsockopt(family, type, proto) + + def settimeout(self, timeout): + self.timeout = timeout + + @staticmethod + def getsockopt(level, optname, buflen=None): + return socket.SOCK_STREAM + + def do_handshake(self): + self._did_handshake = True + + def getpeername(self): + return self._address + + def setblocking(self, block): + self.settimeout(None) if block else self.settimeout(0.0) + + def getblocking(self): + return self.gettimeout() is None + + def getsockname(self): + return socket.gethostbyname(self._address[0]), self._address[1] + + def getpeercert(self, *args, **kwargs): + from mocket.mocket import Mocket + + if not (self._host and self._port): + self._address = self._host, self._port = Mocket._address + + now = datetime.now() + shift = now + timedelta(days=30 * 12) + return { + "notAfter": shift.strftime("%b %d %H:%M:%S GMT"), + "subjectAltName": ( + ("DNS", f"*.{self._host}"), + ("DNS", self._host), + ("DNS", "*"), + ), + "subject": ( + (("organizationName", f"*.{self._host}"),), + (("organizationalUnitName", "Domain Control Validated"),), + (("commonName", f"*.{self._host}"),), + ), + } + + def unwrap(self): + return self + + def write(self, data): + return self.send(encode_to_bytes(data)) + + def connect(self, address): + from mocket.mocket import Mocket + + self._address = self._host, self._port = address + Mocket._address = address + + def makefile(self, mode="r", bufsize=-1): + self._mode = mode + self._bufsize = bufsize + return self.io + + def get_entry(self, data): + from mocket.mocket import Mocket + + return Mocket.get_entry(self._host, self._port, data) + + def sendall(self, data, entry=None, *args, **kwargs): + if entry is None: + entry = self.get_entry(data) + + if entry: + consume_response = entry.collect(data) + response = entry.get_response() if consume_response is not False else None + else: + response = self.true_sendall(data, *args, **kwargs) + + if response is not None: + self.io.seek(0) + self.io.write(response) + self.io.truncate() + self.io.seek(0) + + def read(self, buffersize): + rv = self.io.read(buffersize) + if rv: + self._sent_non_empty_bytes = True + if self._did_handshake and not self._sent_non_empty_bytes: + raise ssl.SSLWantReadError("The operation did not complete (read)") + return rv + + def recv_into(self, buffer, buffersize=None, flags=None): + if hasattr(buffer, "write"): + return buffer.write(self.read(buffersize)) + # buffer is a memoryview + data = self.read(buffersize) + if data: + buffer[: len(data)] = data + return len(data) + + def recv(self, buffersize, flags=None): + from mocket.mocket import Mocket + + r_fd, _ = Mocket.get_pair((self._host, self._port)) + if r_fd: + return os.read(r_fd, buffersize) + data = self.read(buffersize) + if data: + return data + # used by Redis mock + exc = BlockingIOError() + exc.errno = errno.EWOULDBLOCK + exc.args = (0,) + raise exc + + def true_sendall(self, data, *args, **kwargs): + from mocket.mocket import ( + Mocket, + true_gethostbyname, + true_socket, + true_urllib3_ssl_wrap_socket, + ) + + if not MocketMode().is_allowed((self._host, self._port)): + MocketMode.raise_not_allowed() + + req = decode_from_bytes(data) + # make request unique again + req_signature = _hash_request(hasher, req) + # port should be always a string + port = str(self._port) + + # prepare responses dictionary + responses = {} + + if Mocket.get_truesocket_recording_dir(): + path = os.path.join( + Mocket.get_truesocket_recording_dir(), Mocket.get_namespace() + ".json" + ) + # check if there's already a recorded session dumped to a JSON file + try: + with open(path) as f: + responses = json.load(f) + # if not, create a new dictionary + except (FileNotFoundError, JSONDecodeError): + pass + + try: + try: + response_dict = responses[self._host][port][req_signature] + except KeyError: + if hasher is not hashlib.md5: + # Fallback for backwards compatibility + req_signature = _hash_request(hashlib.md5, req) + response_dict = responses[self._host][port][req_signature] + else: + raise + except KeyError: + # preventing next KeyError exceptions + responses.setdefault(self._host, {}) + responses[self._host].setdefault(port, {}) + responses[self._host][port].setdefault(req_signature, {}) + response_dict = responses[self._host][port][req_signature] + + # try to get the response from the dictionary + try: + encoded_response = hexload(response_dict["response"]) + # if not available, call the real sendall + except KeyError: + host, port = self._host, self._port + host = true_gethostbyname(host) + + if isinstance(self.true_socket, true_socket) and self._secure_socket: + self.true_socket = true_urllib3_ssl_wrap_socket( + self.true_socket, + **self.kwargs, + ) + + with contextlib.suppress(OSError, ValueError): + # already connected + self.true_socket.connect((host, port)) + self.true_socket.sendall(data, *args, **kwargs) + encoded_response = b"" + # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 + while True: + more_to_read = select.select([self.true_socket], [], [], 0.1)[0] + if not more_to_read and encoded_response: + break + new_content = self.true_socket.recv(self._buflen) + if not new_content: + break + encoded_response += new_content + + # dump the resulting dictionary to a JSON file + if Mocket.get_truesocket_recording_dir(): + # update the dictionary with request and response lines + response_dict["request"] = req + response_dict["response"] = hexdump(encoded_response) + + with open(path, mode="w") as f: + f.write( + decode_from_bytes( + json.dumps(responses, indent=4, sort_keys=True) + ) + ) + + # response back to .sendall() which writes it to the Mocket socket and flush the BytesIO + return encoded_response + + def send(self, data, *args, **kwargs): # pragma: no cover + from mocket.mocket import Mocket + + entry = self.get_entry(data) + if not entry or (entry and self._entry != entry): + kwargs["entry"] = entry + self.sendall(data, *args, **kwargs) + else: + req = Mocket.last_request() + if hasattr(req, "add_data"): + req.add_data(data) + self._entry = entry + return len(data) + + def close(self): + if self.true_socket and not self.true_socket._closed: + self.true_socket.close() + self._fd = None + + def __getattr__(self, name): + """Do nothing catchall function, for methods like shutdown()""" + + def do_nothing(*args, **kwargs): + pass + + return do_nothing diff --git a/mocket/ssl.py b/mocket/ssl.py index 2e367f16..e4ae44cf 100644 --- a/mocket/ssl.py +++ b/mocket/ssl.py @@ -49,7 +49,7 @@ def wrap_socket(sock, *args, **kwargs): @staticmethod def wrap_bio(incoming, outcoming, *args, **kwargs): - from mocket.mocket import MocketSocket + from mocket.socket import MocketSocket ssl_obj = MocketSocket() ssl_obj._host = kwargs["server_hostname"] From 89055e8a54b41a0ff8d21dcd2d1774e1e95f8667 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 20:34:04 +0100 Subject: [PATCH 28/98] Refactor: introduce state object (#264) * refactor: move enable- and disable-functions from mocket.mocket to mocket.inject * refactor: Mocket - add typing and get rid of cyclic import --- mocket/entry.py | 3 +- mocket/inject.py | 128 +++++++++++++++++++ mocket/io.py | 4 +- mocket/mocket.py | 182 ++++++--------------------- mocket/mocketizer.py | 4 +- mocket/mode.py | 3 +- mocket/plugins/httpretty/__init__.py | 3 +- mocket/socket.py | 30 ++--- mocket/types.py | 5 + tests/test_socket.py | 2 +- 10 files changed, 186 insertions(+), 178 deletions(-) create mode 100644 mocket/inject.py create mode 100644 mocket/types.py diff --git a/mocket/entry.py b/mocket/entry.py index 8fa28bc7..9dbbf442 100644 --- a/mocket/entry.py +++ b/mocket/entry.py @@ -1,6 +1,7 @@ import collections.abc from mocket.compat import encode_to_bytes +from mocket.mocket import Mocket class MocketEntry: @@ -41,8 +42,6 @@ def can_handle(data): return True def collect(self, data): - from mocket import Mocket - req = self.request_cls(data) Mocket.collect(req) diff --git a/mocket/inject.py b/mocket/inject.py new file mode 100644 index 00000000..cba0b40b --- /dev/null +++ b/mocket/inject.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import os +import socket +import ssl + +import urllib3 +from urllib3.connection import match_hostname as urllib3_match_hostname +from urllib3.util.ssl_ import ssl_wrap_socket as urllib3_ssl_wrap_socket + +try: + from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket +except ImportError: + urllib3_wrap_socket = None + + +try: # pragma: no cover + from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 + + pyopenssl_override = True +except ImportError: + pyopenssl_override = False + +true_socket = socket.socket +true_create_connection = socket.create_connection +true_gethostbyname = socket.gethostbyname +true_gethostname = socket.gethostname +true_getaddrinfo = socket.getaddrinfo +true_socketpair = socket.socketpair +true_ssl_wrap_socket = getattr( + ssl, "wrap_socket", None +) # from Py3.12 it's only under SSLContext +true_ssl_socket = ssl.SSLSocket +true_ssl_context = ssl.SSLContext +true_inet_pton = socket.inet_pton +true_urllib3_wrap_socket = urllib3_wrap_socket +true_urllib3_ssl_wrap_socket = urllib3_ssl_wrap_socket +true_urllib3_match_hostname = urllib3_match_hostname + + +def enable( + namespace: str | None = None, + truesocket_recording_dir: str | None = None, +) -> None: + from mocket.mocket import Mocket + from mocket.socket import MocketSocket, create_connection, socketpair + from mocket.ssl import FakeSSLContext + + Mocket._namespace = namespace + Mocket._truesocket_recording_dir = truesocket_recording_dir + + if truesocket_recording_dir and not os.path.isdir(truesocket_recording_dir): + # JSON dumps will be saved here + raise AssertionError + + socket.socket = socket.__dict__["socket"] = MocketSocket + socket._socketobject = socket.__dict__["_socketobject"] = MocketSocket + socket.SocketType = socket.__dict__["SocketType"] = MocketSocket + socket.create_connection = socket.__dict__["create_connection"] = create_connection + socket.gethostname = socket.__dict__["gethostname"] = lambda: "localhost" + socket.gethostbyname = socket.__dict__["gethostbyname"] = lambda host: "127.0.0.1" + socket.getaddrinfo = socket.__dict__["getaddrinfo"] = ( + lambda host, port, family=None, socktype=None, proto=None, flags=None: [ + (2, 1, 6, "", (host, port)) + ] + ) + socket.socketpair = socket.__dict__["socketpair"] = socketpair + ssl.wrap_socket = ssl.__dict__["wrap_socket"] = FakeSSLContext.wrap_socket + ssl.SSLContext = ssl.__dict__["SSLContext"] = FakeSSLContext + socket.inet_pton = socket.__dict__["inet_pton"] = lambda family, ip: bytes( + "\x7f\x00\x00\x01", "utf-8" + ) + urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( + FakeSSLContext.wrap_socket + ) + urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ + "ssl_wrap_socket" + ] = FakeSSLContext.wrap_socket + urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( + FakeSSLContext.wrap_socket + ) + urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ + "ssl_wrap_socket" + ] = FakeSSLContext.wrap_socket + urllib3.connection.match_hostname = urllib3.connection.__dict__[ + "match_hostname" + ] = lambda *args: None + if pyopenssl_override: # pragma: no cover + # Take out the pyopenssl version - use the default implementation + extract_from_urllib3() + + +def disable() -> None: + from mocket.mocket import Mocket + + socket.socket = socket.__dict__["socket"] = true_socket + socket._socketobject = socket.__dict__["_socketobject"] = true_socket + socket.SocketType = socket.__dict__["SocketType"] = true_socket + socket.create_connection = socket.__dict__["create_connection"] = ( + true_create_connection + ) + socket.gethostname = socket.__dict__["gethostname"] = true_gethostname + socket.gethostbyname = socket.__dict__["gethostbyname"] = true_gethostbyname + socket.getaddrinfo = socket.__dict__["getaddrinfo"] = true_getaddrinfo + socket.socketpair = socket.__dict__["socketpair"] = true_socketpair + if true_ssl_wrap_socket: + ssl.wrap_socket = ssl.__dict__["wrap_socket"] = true_ssl_wrap_socket + ssl.SSLContext = ssl.__dict__["SSLContext"] = true_ssl_context + socket.inet_pton = socket.__dict__["inet_pton"] = true_inet_pton + urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( + true_urllib3_wrap_socket + ) + urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ + "ssl_wrap_socket" + ] = true_urllib3_ssl_wrap_socket + urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( + true_urllib3_ssl_wrap_socket + ) + urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ + "ssl_wrap_socket" + ] = true_urllib3_ssl_wrap_socket + urllib3.connection.match_hostname = urllib3.connection.__dict__[ + "match_hostname" + ] = true_urllib3_match_hostname + Mocket.reset() + if pyopenssl_override: # pragma: no cover + # Put the pyopenssl version back in place + inject_into_urllib3() diff --git a/mocket/io.py b/mocket/io.py index 45bb8272..648b16dd 100644 --- a/mocket/io.py +++ b/mocket/io.py @@ -1,6 +1,8 @@ import io import os +from mocket.mocket import Mocket + class MocketSocketCore(io.BytesIO): def __init__(self, address) -> None: @@ -8,8 +10,6 @@ def __init__(self, address) -> None: super().__init__() def write(self, content): - from mocket import Mocket - super().write(content) _, w_fd = Mocket.get_pair(self._address) diff --git a/mocket/mocket.py b/mocket/mocket.py index 6bb0e566..3476902d 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -1,57 +1,33 @@ +from __future__ import annotations + import collections import itertools import os -import socket -import ssl -from typing import Optional, Tuple - -import urllib3 -from urllib3.connection import match_hostname as urllib3_match_hostname -from urllib3.util.ssl_ import ssl_wrap_socket as urllib3_ssl_wrap_socket - -try: - from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket -except ImportError: - urllib3_wrap_socket = None - - -from mocket.socket import MocketSocket, create_connection, socketpair -from mocket.ssl import FakeSSLContext - -try: # pragma: no cover - from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 - - pyopenssl_override = True -except ImportError: - pyopenssl_override = False - -true_socket = socket.socket -true_create_connection = socket.create_connection -true_gethostbyname = socket.gethostbyname -true_gethostname = socket.gethostname -true_getaddrinfo = socket.getaddrinfo -true_socketpair = socket.socketpair -true_ssl_wrap_socket = getattr( - ssl, "wrap_socket", None -) # from Py3.12 it's only under SSLContext -true_ssl_socket = ssl.SSLSocket -true_ssl_context = ssl.SSLContext -true_inet_pton = socket.inet_pton -true_urllib3_wrap_socket = urllib3_wrap_socket -true_urllib3_ssl_wrap_socket = urllib3_ssl_wrap_socket -true_urllib3_match_hostname = urllib3_match_hostname +from typing import TYPE_CHECKING, ClassVar + +import mocket.inject + +# NOTE this is here for backwards-compat to keep old import-paths working +# from mocket.socket import MocketSocket as MocketSocket + +if TYPE_CHECKING: + from mocket.entry import MocketEntry + from mocket.types import Address class Mocket: - _socket_pairs = {} - _address = (None, None) - _entries = collections.defaultdict(list) - _requests = [] - _namespace = str(id(_entries)) - _truesocket_recording_dir = None + _socket_pairs: ClassVar[dict[Address, tuple[int, int]]] = {} + _address: ClassVar[Address] = (None, None) + _entries: ClassVar[dict[Address, list[MocketEntry]]] = collections.defaultdict(list) + _requests: ClassVar[list] = [] + _namespace: ClassVar[str] = str(id(_entries)) + _truesocket_recording_dir: ClassVar[str | None] = None + + enable = mocket.inject.enable + disable = mocket.inject.disable @classmethod - def get_pair(cls, address: tuple) -> Tuple[Optional[int], Optional[int]]: + def get_pair(cls, address: Address) -> tuple[int, int] | tuple[None, None]: """ Given the id() of the caller, return a pair of file descriptors as a tuple of two integers: (, ) @@ -59,7 +35,7 @@ def get_pair(cls, address: tuple) -> Tuple[Optional[int], Optional[int]]: return cls._socket_pairs.get(address, (None, None)) @classmethod - def set_pair(cls, address: tuple, pair: Tuple[int, int]) -> None: + def set_pair(cls, address: Address, pair: tuple[int, int]) -> None: """ Store a pair of file descriptors under the key `id_` as a tuple of two integers: (, ) @@ -67,25 +43,26 @@ def set_pair(cls, address: tuple, pair: Tuple[int, int]) -> None: cls._socket_pairs[address] = pair @classmethod - def register(cls, *entries): + def register(cls, *entries: MocketEntry) -> None: for entry in entries: cls._entries[entry.location].append(entry) @classmethod - def get_entry(cls, host, port, data): - host = host or Mocket._address[0] - port = port or Mocket._address[1] + def get_entry(cls, host: str, port: int, data) -> MocketEntry | None: + host = host or cls._address[0] + port = port or cls._address[1] entries = cls._entries.get((host, port), []) for entry in entries: if entry.can_handle(data): return entry + return None @classmethod - def collect(cls, data): - cls.request_list().append(data) + def collect(cls, data) -> None: + cls._requests.append(data) @classmethod - def reset(cls): + def reset(cls) -> None: for r_fd, w_fd in cls._socket_pairs.values(): os.close(r_fd) os.close(w_fd) @@ -96,116 +73,31 @@ def reset(cls): @classmethod def last_request(cls): if cls.has_requests(): - return cls.request_list()[-1] + return cls._requests[-1] @classmethod def request_list(cls): return cls._requests @classmethod - def remove_last_request(cls): + def remove_last_request(cls) -> None: if cls.has_requests(): del cls._requests[-1] @classmethod - def has_requests(cls): + def has_requests(cls) -> bool: return bool(cls.request_list()) - @staticmethod - def enable(namespace=None, truesocket_recording_dir=None): - Mocket._namespace = namespace - Mocket._truesocket_recording_dir = truesocket_recording_dir - - if truesocket_recording_dir and not os.path.isdir(truesocket_recording_dir): - # JSON dumps will be saved here - raise AssertionError - - socket.socket = socket.__dict__["socket"] = MocketSocket - socket._socketobject = socket.__dict__["_socketobject"] = MocketSocket - socket.SocketType = socket.__dict__["SocketType"] = MocketSocket - socket.create_connection = socket.__dict__["create_connection"] = ( - create_connection - ) - socket.gethostname = socket.__dict__["gethostname"] = lambda: "localhost" - socket.gethostbyname = socket.__dict__["gethostbyname"] = ( - lambda host: "127.0.0.1" - ) - socket.getaddrinfo = socket.__dict__["getaddrinfo"] = ( - lambda host, port, family=None, socktype=None, proto=None, flags=None: [ - (2, 1, 6, "", (host, port)) - ] - ) - socket.socketpair = socket.__dict__["socketpair"] = socketpair - ssl.wrap_socket = ssl.__dict__["wrap_socket"] = FakeSSLContext.wrap_socket - ssl.SSLContext = ssl.__dict__["SSLContext"] = FakeSSLContext - socket.inet_pton = socket.__dict__["inet_pton"] = lambda family, ip: bytes( - "\x7f\x00\x00\x01", "utf-8" - ) - urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( - FakeSSLContext.wrap_socket - ) - urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ - "ssl_wrap_socket" - ] = FakeSSLContext.wrap_socket - urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( - FakeSSLContext.wrap_socket - ) - urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ - "ssl_wrap_socket" - ] = FakeSSLContext.wrap_socket - urllib3.connection.match_hostname = urllib3.connection.__dict__[ - "match_hostname" - ] = lambda *args: None - if pyopenssl_override: # pragma: no cover - # Take out the pyopenssl version - use the default implementation - extract_from_urllib3() - - @staticmethod - def disable(): - socket.socket = socket.__dict__["socket"] = true_socket - socket._socketobject = socket.__dict__["_socketobject"] = true_socket - socket.SocketType = socket.__dict__["SocketType"] = true_socket - socket.create_connection = socket.__dict__["create_connection"] = ( - true_create_connection - ) - socket.gethostname = socket.__dict__["gethostname"] = true_gethostname - socket.gethostbyname = socket.__dict__["gethostbyname"] = true_gethostbyname - socket.getaddrinfo = socket.__dict__["getaddrinfo"] = true_getaddrinfo - socket.socketpair = socket.__dict__["socketpair"] = true_socketpair - if true_ssl_wrap_socket: - ssl.wrap_socket = ssl.__dict__["wrap_socket"] = true_ssl_wrap_socket - ssl.SSLContext = ssl.__dict__["SSLContext"] = true_ssl_context - socket.inet_pton = socket.__dict__["inet_pton"] = true_inet_pton - urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( - true_urllib3_wrap_socket - ) - urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ - "ssl_wrap_socket" - ] = true_urllib3_ssl_wrap_socket - urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( - true_urllib3_ssl_wrap_socket - ) - urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ - "ssl_wrap_socket" - ] = true_urllib3_ssl_wrap_socket - urllib3.connection.match_hostname = urllib3.connection.__dict__[ - "match_hostname" - ] = true_urllib3_match_hostname - Mocket.reset() - if pyopenssl_override: # pragma: no cover - # Put the pyopenssl version back in place - inject_into_urllib3() - @classmethod - def get_namespace(cls): + def get_namespace(cls) -> str: return cls._namespace @classmethod - def get_truesocket_recording_dir(cls): + def get_truesocket_recording_dir(cls) -> str | None: return cls._truesocket_recording_dir @classmethod - def assert_fail_if_entries_not_served(cls): + def assert_fail_if_entries_not_served(cls) -> None: """Mocket checks that all entries have been served at least once.""" if not all(entry._served for entry in itertools.chain(*cls._entries.values())): raise AssertionError("Some Mocket entries have not been served") diff --git a/mocket/mocketizer.py b/mocket/mocketizer.py index 5a988c77..2bf2b9cd 100644 --- a/mocket/mocketizer.py +++ b/mocket/mocketizer.py @@ -1,3 +1,4 @@ +from mocket.mocket import Mocket from mocket.mode import MocketMode from mocket.utils import get_mocketize @@ -23,8 +24,6 @@ def __init__( ) def enter(self): - from mocket import Mocket - Mocket.enable( namespace=self.namespace, truesocket_recording_dir=self.truesocket_recording_dir, @@ -39,7 +38,6 @@ def __enter__(self): def exit(self): if self.instance: self.check_and_call("mocketize_teardown") - from mocket import Mocket Mocket.disable() diff --git a/mocket/mode.py b/mocket/mode.py index 3c0638e5..e1da7955 100644 --- a/mocket/mode.py +++ b/mocket/mode.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from mocket.exceptions import StrictMocketException +from mocket.mocket import Mocket if TYPE_CHECKING: # pragma: no cover from typing import NoReturn @@ -31,8 +32,6 @@ def is_allowed(self, location: str | tuple[str, int]) -> bool: @staticmethod def raise_not_allowed() -> NoReturn: - from mocket.mocket import Mocket - current_entries = [ (location, "\n ".join(map(str, entries))) for location, entries in Mocket._entries.items() diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index d5e41e30..fac61840 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -1,6 +1,7 @@ -from mocket import Mocket, mocketize +from mocket import mocketize from mocket.async_mocket import async_mocketize from mocket.compat import ENCODING +from mocket.mocket import Mocket from mocket.mockhttp import Entry as MocketHttpEntry from mocket.mockhttp import Request as MocketHttpRequest from mocket.mockhttp import Response as MocketHttpResponse diff --git a/mocket/socket.py b/mocket/socket.py index 3a971af5..e4be00b6 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -10,7 +10,13 @@ from json.decoder import JSONDecodeError from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.inject import ( + true_gethostbyname, + true_socket, + true_urllib3_ssl_wrap_socket, +) from mocket.io import MocketSocketCore +from mocket.mocket import Mocket from mocket.mode import MocketMode from mocket.utils import hexdump, hexload @@ -63,8 +69,6 @@ class MocketSocket: def __init__( self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, **kwargs ): - from mocket.mocket import true_socket - self.true_socket = true_socket(family, type, proto) self._buflen = 65536 self._entry = None @@ -90,8 +94,6 @@ def io(self): return self._io def fileno(self): - from mocket.mocket import Mocket - address = (self._host, self._port) r_fd, _ = Mocket.get_pair(address) if not r_fd: @@ -133,8 +135,6 @@ def getsockname(self): return socket.gethostbyname(self._address[0]), self._address[1] def getpeercert(self, *args, **kwargs): - from mocket.mocket import Mocket - if not (self._host and self._port): self._address = self._host, self._port = Mocket._address @@ -161,8 +161,6 @@ def write(self, data): return self.send(encode_to_bytes(data)) def connect(self, address): - from mocket.mocket import Mocket - self._address = self._host, self._port = address Mocket._address = address @@ -172,8 +170,6 @@ def makefile(self, mode="r", bufsize=-1): return self.io def get_entry(self, data): - from mocket.mocket import Mocket - return Mocket.get_entry(self._host, self._port, data) def sendall(self, data, entry=None, *args, **kwargs): @@ -210,8 +206,6 @@ def recv_into(self, buffer, buffersize=None, flags=None): return len(data) def recv(self, buffersize, flags=None): - from mocket.mocket import Mocket - r_fd, _ = Mocket.get_pair((self._host, self._port)) if r_fd: return os.read(r_fd, buffersize) @@ -225,13 +219,6 @@ def recv(self, buffersize, flags=None): raise exc def true_sendall(self, data, *args, **kwargs): - from mocket.mocket import ( - Mocket, - true_gethostbyname, - true_socket, - true_urllib3_ssl_wrap_socket, - ) - if not MocketMode().is_allowed((self._host, self._port)): MocketMode.raise_not_allowed() @@ -246,7 +233,8 @@ def true_sendall(self, data, *args, **kwargs): if Mocket.get_truesocket_recording_dir(): path = os.path.join( - Mocket.get_truesocket_recording_dir(), Mocket.get_namespace() + ".json" + Mocket.get_truesocket_recording_dir(), + Mocket.get_namespace() + ".json", ) # check if there's already a recorded session dumped to a JSON file try: @@ -319,8 +307,6 @@ def true_sendall(self, data, *args, **kwargs): return encoded_response def send(self, data, *args, **kwargs): # pragma: no cover - from mocket.mocket import Mocket - entry = self.get_entry(data) if not entry or (entry and self._entry != entry): kwargs["entry"] = entry diff --git a/mocket/types.py b/mocket/types.py new file mode 100644 index 00000000..61b7a4d5 --- /dev/null +++ b/mocket/types.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Tuple + +Address = Tuple[str, int] diff --git a/tests/test_socket.py b/tests/test_socket.py index 8a6e65ad..112a9089 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -2,7 +2,7 @@ import pytest -from mocket.mocket import MocketSocket +from mocket.socket import MocketSocket @pytest.mark.parametrize("blocking", (False, True)) From 4dc38cafa50b499bf783998e55a87e8446b2dce5 Mon Sep 17 00:00:00 2001 From: betaboon Date: Mon, 18 Nov 2024 10:16:24 +0100 Subject: [PATCH 29/98] refactor: type SuperFakeSSLContext and FakeSSLContext --- mocket/ssl.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/mocket/ssl.py b/mocket/ssl.py index e4ae44cf..9d9d5d3b 100644 --- a/mocket/ssl.py +++ b/mocket/ssl.py @@ -1,8 +1,15 @@ +from __future__ import annotations + +from typing import Any + +from mocket.socket import MocketSocket + + class SuperFakeSSLContext: """For Python 3.6 and newer.""" class FakeSetter(int): - def __set__(self, *args): + def __set__(self, *args: Any) -> None: pass minimum_version = FakeSetter() @@ -24,33 +31,36 @@ class FakeSSLContext(SuperFakeSSLContext): _check_hostname = False @property - def check_hostname(self): + def check_hostname(self) -> bool: return self._check_hostname @check_hostname.setter - def check_hostname(self, _): + def check_hostname(self, _: bool) -> None: self._check_hostname = False - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self._set_dummy_methods() - def _set_dummy_methods(self): - def dummy_method(*args, **kwargs): + def _set_dummy_methods(self) -> None: + def dummy_method(*args: Any, **kwargs: Any) -> Any: pass for m in self.DUMMY_METHODS: setattr(self, m, dummy_method) @staticmethod - def wrap_socket(sock, *args, **kwargs): + def wrap_socket(sock: MocketSocket, *args: Any, **kwargs: Any) -> MocketSocket: sock.kwargs = kwargs sock._secure_socket = True return sock @staticmethod - def wrap_bio(incoming, outcoming, *args, **kwargs): - from mocket.socket import MocketSocket - + def wrap_bio( + incoming: Any, # _ssl.MemoryBIO + outgoing: Any, # _ssl.MemoryBIO + server_side: bool = False, + server_hostname: str | bytes | None = None, + ) -> MocketSocket: ssl_obj = MocketSocket() - ssl_obj._host = kwargs["server_hostname"] + ssl_obj._host = server_hostname return ssl_obj From ba68b9cd4ac1941b3c333a79d32936e9f2193aef Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 17:40:19 +0100 Subject: [PATCH 30/98] refactor: move FakeSSLContext from mocket.ssl to mocket.ssl.context --- mocket/__init__.py | 2 +- mocket/inject.py | 2 +- mocket/ssl/__init__.py | 0 mocket/{ssl.py => ssl/context.py} | 0 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 mocket/ssl/__init__.py rename mocket/{ssl.py => ssl/context.py} (100%) diff --git a/mocket/__init__.py b/mocket/__init__.py index d64cb11d..58993a24 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -2,7 +2,7 @@ from mocket.entry import MocketEntry from mocket.mocket import Mocket from mocket.mocketizer import Mocketizer, mocketize -from mocket.ssl import FakeSSLContext +from mocket.ssl.context import FakeSSLContext __all__ = ( "async_mocketize", diff --git a/mocket/inject.py b/mocket/inject.py index cba0b40b..b39503ed 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -44,7 +44,7 @@ def enable( ) -> None: from mocket.mocket import Mocket from mocket.socket import MocketSocket, create_connection, socketpair - from mocket.ssl import FakeSSLContext + from mocket.ssl.context import FakeSSLContext Mocket._namespace = namespace Mocket._truesocket_recording_dir = truesocket_recording_dir diff --git a/mocket/ssl/__init__.py b/mocket/ssl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mocket/ssl.py b/mocket/ssl/context.py similarity index 100% rename from mocket/ssl.py rename to mocket/ssl/context.py From 942c33f379a1e0fc19122ecc9424ceeb6d270fef Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 18:15:48 +0100 Subject: [PATCH 31/98] refactor: move true_* from mocket.inject to mocket.socket and mocket.ssl.context --- mocket/inject.py | 71 +++++++++++++++++++++---------------------- mocket/socket.py | 69 ++++++++++++++++++++++++++++++++++++----- mocket/ssl/context.py | 3 ++ 3 files changed, 98 insertions(+), 45 deletions(-) diff --git a/mocket/inject.py b/mocket/inject.py index b39503ed..5909cb93 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -5,14 +5,6 @@ import ssl import urllib3 -from urllib3.connection import match_hostname as urllib3_match_hostname -from urllib3.util.ssl_ import ssl_wrap_socket as urllib3_ssl_wrap_socket - -try: - from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket -except ImportError: - urllib3_wrap_socket = None - try: # pragma: no cover from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 @@ -21,29 +13,22 @@ except ImportError: pyopenssl_override = False -true_socket = socket.socket -true_create_connection = socket.create_connection -true_gethostbyname = socket.gethostbyname -true_gethostname = socket.gethostname -true_getaddrinfo = socket.getaddrinfo -true_socketpair = socket.socketpair -true_ssl_wrap_socket = getattr( - ssl, "wrap_socket", None -) # from Py3.12 it's only under SSLContext -true_ssl_socket = ssl.SSLSocket -true_ssl_context = ssl.SSLContext -true_inet_pton = socket.inet_pton -true_urllib3_wrap_socket = urllib3_wrap_socket -true_urllib3_ssl_wrap_socket = urllib3_ssl_wrap_socket -true_urllib3_match_hostname = urllib3_match_hostname - def enable( namespace: str | None = None, truesocket_recording_dir: str | None = None, ) -> None: from mocket.mocket import Mocket - from mocket.socket import MocketSocket, create_connection, socketpair + from mocket.socket import ( + MocketSocket, + mock_create_connection, + mock_getaddrinfo, + mock_gethostbyname, + mock_gethostname, + mock_inet_pton, + mock_socketpair, + mock_urllib3_match_hostname, + ) from mocket.ssl.context import FakeSSLContext Mocket._namespace = namespace @@ -56,20 +41,16 @@ def enable( socket.socket = socket.__dict__["socket"] = MocketSocket socket._socketobject = socket.__dict__["_socketobject"] = MocketSocket socket.SocketType = socket.__dict__["SocketType"] = MocketSocket - socket.create_connection = socket.__dict__["create_connection"] = create_connection - socket.gethostname = socket.__dict__["gethostname"] = lambda: "localhost" - socket.gethostbyname = socket.__dict__["gethostbyname"] = lambda host: "127.0.0.1" - socket.getaddrinfo = socket.__dict__["getaddrinfo"] = ( - lambda host, port, family=None, socktype=None, proto=None, flags=None: [ - (2, 1, 6, "", (host, port)) - ] + socket.create_connection = socket.__dict__["create_connection"] = ( + mock_create_connection ) - socket.socketpair = socket.__dict__["socketpair"] = socketpair + socket.gethostname = socket.__dict__["gethostname"] = mock_gethostname + socket.gethostbyname = socket.__dict__["gethostbyname"] = mock_gethostbyname + socket.getaddrinfo = socket.__dict__["getaddrinfo"] = mock_getaddrinfo + socket.socketpair = socket.__dict__["socketpair"] = mock_socketpair ssl.wrap_socket = ssl.__dict__["wrap_socket"] = FakeSSLContext.wrap_socket ssl.SSLContext = ssl.__dict__["SSLContext"] = FakeSSLContext - socket.inet_pton = socket.__dict__["inet_pton"] = lambda family, ip: bytes( - "\x7f\x00\x00\x01", "utf-8" - ) + socket.inet_pton = socket.__dict__["inet_pton"] = mock_inet_pton urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( FakeSSLContext.wrap_socket ) @@ -84,7 +65,7 @@ def enable( ] = FakeSSLContext.wrap_socket urllib3.connection.match_hostname = urllib3.connection.__dict__[ "match_hostname" - ] = lambda *args: None + ] = mock_urllib3_match_hostname if pyopenssl_override: # pragma: no cover # Take out the pyopenssl version - use the default implementation extract_from_urllib3() @@ -92,6 +73,22 @@ def enable( def disable() -> None: from mocket.mocket import Mocket + from mocket.socket import ( + true_create_connection, + true_getaddrinfo, + true_gethostbyname, + true_gethostname, + true_inet_pton, + true_socket, + true_socketpair, + true_ssl_wrap_socket, + true_urllib3_match_hostname, + true_urllib3_ssl_wrap_socket, + true_urllib3_wrap_socket, + ) + from mocket.ssl.context import ( + true_ssl_context, + ) socket.socket = socket.__dict__["socket"] = true_socket socket._socketobject = socket.__dict__["_socketobject"] = true_socket diff --git a/mocket/socket.py b/mocket/socket.py index e4be00b6..ab711f06 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import errno import hashlib @@ -8,18 +10,42 @@ import ssl from datetime import datetime, timedelta from json.decoder import JSONDecodeError +from typing import Any + +import urllib3.connection +import urllib3.util.ssl_ from mocket.compat import decode_from_bytes, encode_to_bytes -from mocket.inject import ( - true_gethostbyname, - true_socket, - true_urllib3_ssl_wrap_socket, -) from mocket.io import MocketSocketCore from mocket.mocket import Mocket from mocket.mode import MocketMode from mocket.utils import hexdump, hexload +true_create_connection = socket.create_connection +true_getaddrinfo = socket.getaddrinfo +true_gethostbyname = socket.gethostbyname +true_gethostname = socket.gethostname +true_inet_pton = socket.inet_pton +true_socket = socket.socket +true_socketpair = socket.socketpair +true_ssl_wrap_socket = None + +true_urllib3_match_hostname = urllib3.connection.match_hostname +true_urllib3_ssl_wrap_socket = urllib3.util.ssl_.ssl_wrap_socket +true_urllib3_wrap_socket = None + +with contextlib.suppress(ImportError): + # from Py3.12 it's only under SSLContext + from ssl import wrap_socket as ssl_wrap_socket + + true_ssl_wrap_socket = ssl_wrap_socket + +with contextlib.suppress(ImportError): + from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket + + true_urllib3_wrap_socket = urllib3_wrap_socket + + xxh32 = None try: from xxhash import xxh32 @@ -29,7 +55,7 @@ hasher = xxh32 or hashlib.md5 -def create_connection(address, timeout=None, source_address=None): +def mock_create_connection(address, timeout=None, source_address=None): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) if timeout: s.settimeout(timeout) @@ -37,13 +63,40 @@ def create_connection(address, timeout=None, source_address=None): return s -def socketpair(*args, **kwargs): +def mock_getaddrinfo( + host: str, + port: int, + family: int = 0, + type: int = 0, + proto: int = 0, + flags: int = 0, +) -> list[tuple[int, int, int, str, tuple[str, int]]]: + return [(2, 1, 6, "", (host, port))] + + +def mock_gethostbyname(hostname: str) -> str: + return "127.0.0.1" + + +def mock_gethostname() -> str: + return "localhost" + + +def mock_inet_pton(address_family: int, ip_string: str) -> bytes: + return bytes("\x7f\x00\x00\x01", "utf-8") + + +def mock_socketpair(*args, **kwargs): """Returns a real socketpair() used by asyncio loop for supporting calls made by fastapi and similar services.""" import _socket return _socket.socketpair(*args, **kwargs) +def mock_urllib3_match_hostname(*args: Any) -> None: + return None + + def _hash_request(h, req): return h(encode_to_bytes("".join(sorted(req.split("\r\n"))))).hexdigest() @@ -132,7 +185,7 @@ def getblocking(self): return self.gettimeout() is None def getsockname(self): - return socket.gethostbyname(self._address[0]), self._address[1] + return true_gethostbyname(self._address[0]), self._address[1] def getpeercert(self, *args, **kwargs): if not (self._host and self._port): diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index 9d9d5d3b..a327fbef 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -1,9 +1,12 @@ from __future__ import annotations +import ssl from typing import Any from mocket.socket import MocketSocket +true_ssl_context = ssl.SSLContext + class SuperFakeSSLContext: """For Python 3.6 and newer.""" From cfcd85c642cfa3847a7af1b5b81c9052846aa146 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 18:59:58 +0100 Subject: [PATCH 32/98] refactor: type MocketSocket --- mocket/socket.py | 93 ++++++++++++++++++++++++++++++++---------------- mocket/types.py | 17 ++++++++- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/mocket/socket.py b/mocket/socket.py index ab711f06..3743e6f2 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -10,15 +10,25 @@ import ssl from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from types import TracebackType +from typing import Any, Type import urllib3.connection import urllib3.util.ssl_ +from typing_extensions import Self from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.entry import MocketEntry from mocket.io import MocketSocketCore from mocket.mocket import Mocket from mocket.mode import MocketMode +from mocket.types import ( + Address, + ReadableBuffer, + WriteableBuffer, + _PeerCertRetDictType, + _RetAddress, +) from mocket.utils import hexdump, hexload true_create_connection = socket.create_connection @@ -120,8 +130,13 @@ class MocketSocket: _io = None def __init__( - self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, **kwargs - ): + self, + family: socket.AddressFamily | int = socket.AF_INET, + type: socket.SocketKind | int = socket.SOCK_STREAM, + proto: int = 0, + fileno: int | None = None, + **kwargs: Any, + ) -> None: self.true_socket = true_socket(family, type, proto) self._buflen = 65536 self._entry = None @@ -131,22 +146,27 @@ def __init__( self._truesocket_recording_dir = None self.kwargs = kwargs - def __str__(self): + def __str__(self) -> str: return f"({self.__class__.__name__})(family={self.family} type={self.type} protocol={self.proto})" - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + type_: Type[BaseException] | None, # noqa: UP006 + value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.close() @property - def io(self): + def io(self) -> MocketSocketCore: if self._io is None: self._io = MocketSocketCore((self._host, self._port)) return self._io - def fileno(self): + def fileno(self) -> int: address = (self._host, self._port) r_fd, _ = Mocket.get_pair(address) if not r_fd: @@ -154,10 +174,11 @@ def fileno(self): Mocket.set_pair(address, (r_fd, w_fd)) return r_fd - def gettimeout(self): + def gettimeout(self) -> float | None: return self.timeout - def setsockopt(self, family, type, proto): + # FIXME the arguments here seem wrong. they should be `level: int, optname: int, value: int | ReadableBuffer | None` + def setsockopt(self, family: int, type: int, proto: int) -> None: self.family = family self.type = type self.proto = proto @@ -165,29 +186,29 @@ def setsockopt(self, family, type, proto): if self.true_socket: self.true_socket.setsockopt(family, type, proto) - def settimeout(self, timeout): + def settimeout(self, timeout: float | None) -> None: self.timeout = timeout @staticmethod - def getsockopt(level, optname, buflen=None): + def getsockopt(level: int, optname: int, buflen: int | None = None) -> int: return socket.SOCK_STREAM - def do_handshake(self): + def do_handshake(self) -> None: self._did_handshake = True - def getpeername(self): + def getpeername(self) -> _RetAddress: return self._address - def setblocking(self, block): + def setblocking(self, block: bool) -> None: self.settimeout(None) if block else self.settimeout(0.0) - def getblocking(self): + def getblocking(self) -> bool: return self.gettimeout() is None - def getsockname(self): + def getsockname(self) -> _RetAddress: return true_gethostbyname(self._address[0]), self._address[1] - def getpeercert(self, *args, **kwargs): + def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: if not (self._host and self._port): self._address = self._host, self._port = Mocket._address @@ -207,22 +228,22 @@ def getpeercert(self, *args, **kwargs): ), } - def unwrap(self): + def unwrap(self) -> MocketSocket: return self - def write(self, data): + def write(self, data: bytes) -> int | None: return self.send(encode_to_bytes(data)) - def connect(self, address): + def connect(self, address: Address) -> None: self._address = self._host, self._port = address Mocket._address = address - def makefile(self, mode="r", bufsize=-1): + def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketCore: self._mode = mode self._bufsize = bufsize return self.io - def get_entry(self, data): + def get_entry(self, data: bytes) -> MocketEntry | None: return Mocket.get_entry(self._host, self._port, data) def sendall(self, data, entry=None, *args, **kwargs): @@ -241,7 +262,7 @@ def sendall(self, data, entry=None, *args, **kwargs): self.io.truncate() self.io.seek(0) - def read(self, buffersize): + def read(self, buffersize: int | None = None) -> bytes: rv = self.io.read(buffersize) if rv: self._sent_non_empty_bytes = True @@ -249,7 +270,12 @@ def read(self, buffersize): raise ssl.SSLWantReadError("The operation did not complete (read)") return rv - def recv_into(self, buffer, buffersize=None, flags=None): + def recv_into( + self, + buffer: WriteableBuffer, + buffersize: int | None = None, + flags: int | None = None, + ) -> int: if hasattr(buffer, "write"): return buffer.write(self.read(buffersize)) # buffer is a memoryview @@ -258,7 +284,7 @@ def recv_into(self, buffer, buffersize=None, flags=None): buffer[: len(data)] = data return len(data) - def recv(self, buffersize, flags=None): + def recv(self, buffersize: int, flags: int | None = None) -> bytes: r_fd, _ = Mocket.get_pair((self._host, self._port)) if r_fd: return os.read(r_fd, buffersize) @@ -271,7 +297,7 @@ def recv(self, buffersize, flags=None): exc.args = (0,) raise exc - def true_sendall(self, data, *args, **kwargs): + def true_sendall(self, data: ReadableBuffer, *args: Any, **kwargs: Any) -> int: if not MocketMode().is_allowed((self._host, self._port)): MocketMode.raise_not_allowed() @@ -359,7 +385,12 @@ def true_sendall(self, data, *args, **kwargs): # response back to .sendall() which writes it to the Mocket socket and flush the BytesIO return encoded_response - def send(self, data, *args, **kwargs): # pragma: no cover + def send( + self, + data: ReadableBuffer, + *args: Any, + **kwargs: Any, + ) -> int: # pragma: no cover entry = self.get_entry(data) if not entry or (entry and self._entry != entry): kwargs["entry"] = entry @@ -371,15 +402,15 @@ def send(self, data, *args, **kwargs): # pragma: no cover self._entry = entry return len(data) - def close(self): + def close(self) -> None: if self.true_socket and not self.true_socket._closed: self.true_socket.close() self._fd = None - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: """Do nothing catchall function, for methods like shutdown()""" - def do_nothing(*args, **kwargs): + def do_nothing(*args: Any, **kwargs: Any) -> Any: pass return do_nothing diff --git a/mocket/types.py b/mocket/types.py index 61b7a4d5..562648c7 100644 --- a/mocket/types.py +++ b/mocket/types.py @@ -1,5 +1,20 @@ from __future__ import annotations -from typing import Tuple +from typing import Any, Dict, Tuple, Union + +from typing_extensions import Buffer, TypeAlias Address = Tuple[str, int] + +# adapted from typeshed/stdlib/_typeshed/__init__.pyi +WriteableBuffer: TypeAlias = Buffer +ReadableBuffer: TypeAlias = Buffer + +# from typeshed/stdlib/_socket.pyi +_Address: TypeAlias = Union[Tuple[Any, ...], str, ReadableBuffer] +_RetAddress: TypeAlias = Any + +# from typeshed/stdlib/ssl.pyi +_PCTRTT: TypeAlias = Tuple[Tuple[str, str], ...] +_PCTRTTT: TypeAlias = Tuple[_PCTRTT, ...] +_PeerCertRetDictType: TypeAlias = Dict[str, Union[str, _PCTRTTT, _PCTRTT]] From 9050127e34dcd121086e68ae657d05e51d414425 Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 19:02:08 +0100 Subject: [PATCH 33/98] refactor: remove unused instance-variables from MocketSocket --- mocket/socket.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mocket/socket.py b/mocket/socket.py index 3743e6f2..c4b6a9a8 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -113,7 +113,6 @@ def _hash_request(h, req): class MocketSocket: timeout = None - _fd = None family = None type = None proto = None @@ -122,8 +121,6 @@ class MocketSocket: _address = None cipher = lambda s: ("ADH", "AES256", "SHA") compression = lambda s: ssl.OP_NO_COMPRESSION - _mode = None - _bufsize = None _secure_socket = False _did_handshake = False _sent_non_empty_bytes = False @@ -239,8 +236,6 @@ def connect(self, address: Address) -> None: Mocket._address = address def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketCore: - self._mode = mode - self._bufsize = bufsize return self.io def get_entry(self, data: bytes) -> MocketEntry | None: @@ -405,7 +400,6 @@ def send( def close(self) -> None: if self.true_socket and not self.true_socket._closed: self.true_socket.close() - self._fd = None def __getattr__(self, name: str) -> Any: """Do nothing catchall function, for methods like shutdown()""" From 1eb61cf55ea7a0445cfd7eee33a87b8fc936858c Mon Sep 17 00:00:00 2001 From: betaboon Date: Sun, 17 Nov 2024 19:13:12 +0100 Subject: [PATCH 34/98] refactor: MocketSocket - make instance-variables private and move into constructor --- mocket/socket.py | 81 +++++++++++++++++++++++++------------------ mocket/ssl/context.py | 2 +- 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/mocket/socket.py b/mocket/socket.py index c4b6a9a8..0b345572 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -112,19 +112,8 @@ def _hash_request(h, req): class MocketSocket: - timeout = None - family = None - type = None - proto = None - _host = None - _port = None - _address = None cipher = lambda s: ("ADH", "AES256", "SHA") compression = lambda s: ssl.OP_NO_COMPRESSION - _secure_socket = False - _did_handshake = False - _sent_non_empty_bytes = False - _io = None def __init__( self, @@ -134,14 +123,26 @@ def __init__( fileno: int | None = None, **kwargs: Any, ) -> None: - self.true_socket = true_socket(family, type, proto) + self._family = family + self._type = type + self._proto = proto + + self._kwargs = kwargs + self._true_socket = true_socket(family, type, proto) + self._buflen = 65536 + self._timeout: float | None = None + + self._secure_socket = False + self._did_handshake = False + self._sent_non_empty_bytes = False + + self._host = None + self._port = None + self._address = None + + self._io = None self._entry = None - self.family = int(family) - self.type = int(type) - self.proto = int(proto) - self._truesocket_recording_dir = None - self.kwargs = kwargs def __str__(self) -> str: return f"({self.__class__.__name__})(family={self.family} type={self.type} protocol={self.proto})" @@ -157,6 +158,18 @@ def __exit__( ) -> None: self.close() + @property + def family(self) -> int: + return self._family + + @property + def type(self) -> int: + return self._type + + @property + def proto(self) -> int: + return self._proto + @property def io(self) -> MocketSocketCore: if self._io is None: @@ -172,19 +185,19 @@ def fileno(self) -> int: return r_fd def gettimeout(self) -> float | None: - return self.timeout + return self._timeout # FIXME the arguments here seem wrong. they should be `level: int, optname: int, value: int | ReadableBuffer | None` def setsockopt(self, family: int, type: int, proto: int) -> None: - self.family = family - self.type = type - self.proto = proto + self._family = family + self._type = type + self._proto = proto - if self.true_socket: - self.true_socket.setsockopt(family, type, proto) + if self._true_socket: + self._true_socket.setsockopt(family, type, proto) def settimeout(self, timeout: float | None) -> None: - self.timeout = timeout + self._timeout = timeout @staticmethod def getsockopt(level: int, optname: int, buflen: int | None = None) -> int: @@ -343,23 +356,23 @@ def true_sendall(self, data: ReadableBuffer, *args: Any, **kwargs: Any) -> int: host, port = self._host, self._port host = true_gethostbyname(host) - if isinstance(self.true_socket, true_socket) and self._secure_socket: - self.true_socket = true_urllib3_ssl_wrap_socket( - self.true_socket, - **self.kwargs, + if isinstance(self._true_socket, true_socket) and self._secure_socket: + self._true_socket = true_urllib3_ssl_wrap_socket( + self._true_socket, + **self._kwargs, ) with contextlib.suppress(OSError, ValueError): # already connected - self.true_socket.connect((host, port)) - self.true_socket.sendall(data, *args, **kwargs) + self._true_socket.connect((host, port)) + self._true_socket.sendall(data, *args, **kwargs) encoded_response = b"" # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 while True: - more_to_read = select.select([self.true_socket], [], [], 0.1)[0] + more_to_read = select.select([self._true_socket], [], [], 0.1)[0] if not more_to_read and encoded_response: break - new_content = self.true_socket.recv(self._buflen) + new_content = self._true_socket.recv(self._buflen) if not new_content: break encoded_response += new_content @@ -398,8 +411,8 @@ def send( return len(data) def close(self) -> None: - if self.true_socket and not self.true_socket._closed: - self.true_socket.close() + if self._true_socket and not self._true_socket._closed: + self._true_socket.close() def __getattr__(self, name: str) -> Any: """Do nothing catchall function, for methods like shutdown()""" diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index a327fbef..a830c1e7 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -53,7 +53,7 @@ def dummy_method(*args: Any, **kwargs: Any) -> Any: @staticmethod def wrap_socket(sock: MocketSocket, *args: Any, **kwargs: Any) -> MocketSocket: - sock.kwargs = kwargs + sock._kwargs = kwargs sock._secure_socket = True return sock From 0eff8f1ec935124b0d6097ecc366d8e758220eda Mon Sep 17 00:00:00 2001 From: betaboon Date: Mon, 18 Nov 2024 09:29:23 +0100 Subject: [PATCH 35/98] refactor: move true-ssl-methods to mocket.ssl.context --- mocket/inject.py | 6 +++--- mocket/socket.py | 18 ++---------------- mocket/ssl/context.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/mocket/inject.py b/mocket/inject.py index 5909cb93..b733dd3c 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -81,13 +81,13 @@ def disable() -> None: true_inet_pton, true_socket, true_socketpair, - true_ssl_wrap_socket, true_urllib3_match_hostname, - true_urllib3_ssl_wrap_socket, - true_urllib3_wrap_socket, ) from mocket.ssl.context import ( true_ssl_context, + true_ssl_wrap_socket, + true_urllib3_ssl_wrap_socket, + true_urllib3_wrap_socket, ) socket.socket = socket.__dict__["socket"] = true_socket diff --git a/mocket/socket.py b/mocket/socket.py index 0b345572..c3bed15f 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -14,7 +14,6 @@ from typing import Any, Type import urllib3.connection -import urllib3.util.ssl_ from typing_extensions import Self from mocket.compat import decode_from_bytes, encode_to_bytes @@ -38,22 +37,7 @@ true_inet_pton = socket.inet_pton true_socket = socket.socket true_socketpair = socket.socketpair -true_ssl_wrap_socket = None - true_urllib3_match_hostname = urllib3.connection.match_hostname -true_urllib3_ssl_wrap_socket = urllib3.util.ssl_.ssl_wrap_socket -true_urllib3_wrap_socket = None - -with contextlib.suppress(ImportError): - # from Py3.12 it's only under SSLContext - from ssl import wrap_socket as ssl_wrap_socket - - true_ssl_wrap_socket = ssl_wrap_socket - -with contextlib.suppress(ImportError): - from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket - - true_urllib3_wrap_socket = urllib3_wrap_socket xxh32 = None @@ -357,6 +341,8 @@ def true_sendall(self, data: ReadableBuffer, *args: Any, **kwargs: Any) -> int: host = true_gethostbyname(host) if isinstance(self._true_socket, true_socket) and self._secure_socket: + from mocket.ssl.context import true_urllib3_ssl_wrap_socket + self._true_socket = true_urllib3_ssl_wrap_socket( self._true_socket, **self._kwargs, diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index a830c1e7..fccf5db4 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -1,12 +1,30 @@ from __future__ import annotations +import contextlib import ssl from typing import Any +import urllib3.util.ssl_ + from mocket.socket import MocketSocket true_ssl_context = ssl.SSLContext +true_ssl_wrap_socket = None +true_urllib3_ssl_wrap_socket = urllib3.util.ssl_.ssl_wrap_socket +true_urllib3_wrap_socket = None + +with contextlib.suppress(ImportError): + # from Py3.12 it's only under SSLContext + from ssl import wrap_socket as ssl_wrap_socket + + true_ssl_wrap_socket = ssl_wrap_socket + +with contextlib.suppress(ImportError): + from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket + + true_urllib3_wrap_socket = urllib3_wrap_socket + class SuperFakeSSLContext: """For Python 3.6 and newer.""" From 90eb5db6929f12793413ac3894b53fc175b269c2 Mon Sep 17 00:00:00 2001 From: betaboon Date: Mon, 18 Nov 2024 09:38:27 +0100 Subject: [PATCH 36/98] refactor: prepare for removal of read and write from MocketSocket --- mocket/socket.py | 10 +++++++--- tests/test_http.py | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mocket/socket.py b/mocket/socket.py index c3bed15f..3cd68fe5 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -269,9 +269,13 @@ def recv_into( flags: int | None = None, ) -> int: if hasattr(buffer, "write"): - return buffer.write(self.read(buffersize)) + return buffer.write(self.recv(buffersize)) + # buffer is a memoryview - data = self.read(buffersize) + if buffersize is None: + buffersize = len(buffer) + + data = self.recv(buffersize) if data: buffer[: len(data)] = data return len(data) @@ -280,7 +284,7 @@ def recv(self, buffersize: int, flags: int | None = None) -> bytes: r_fd, _ = Mocket.get_pair((self._host, self._port)) if r_fd: return os.read(r_fd, buffersize) - data = self.read(buffersize) + data = self.io.read(buffersize) if data: return data # used by Redis mock diff --git a/tests/test_http.py b/tests/test_http.py index d516068b..afa31185 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -359,12 +359,12 @@ def test_sockets(self): sock = socket.socket(address[0], address[1], address[2]) sock.connect(address[-1]) - sock.write(f"{method} {path} HTTP/1.0\r\n") - sock.write(f"Host: {host}\r\n") - sock.write("Content-Type: application/json\r\n") - sock.write("Content-Length: %d\r\n" % len(data)) - sock.write("Connection: close\r\n\r\n") - sock.write(data) + sock.send(f"{method} {path} HTTP/1.0\r\n".encode()) + sock.send(f"Host: {host}\r\n".encode()) + sock.send(b"Content-Type: application/json\r\n") + sock.send(b"Content-Length: %d\r\n" % len(data)) + sock.send(b"Connection: close\r\n\r\n") + sock.send(data.encode()) sock.close() # Proof that worked. From 636951f2f9ea47139539b346c0e3bbc9067e86f0 Mon Sep 17 00:00:00 2001 From: betaboon Date: Mon, 18 Nov 2024 10:10:41 +0100 Subject: [PATCH 37/98] refactor: split ssl-functionality of MocketSocket into MocketSSLSocket --- mocket/socket.py | 52 ------------------------------------ mocket/ssl/context.py | 29 +++++++++++++++----- mocket/ssl/socket.py | 62 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 58 deletions(-) create mode 100644 mocket/ssl/socket.py diff --git a/mocket/socket.py b/mocket/socket.py index 3cd68fe5..2ce74c09 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -96,9 +96,6 @@ def _hash_request(h, req): class MocketSocket: - cipher = lambda s: ("ADH", "AES256", "SHA") - compression = lambda s: ssl.OP_NO_COMPRESSION - def __init__( self, family: socket.AddressFamily | int = socket.AF_INET, @@ -117,10 +114,6 @@ def __init__( self._buflen = 65536 self._timeout: float | None = None - self._secure_socket = False - self._did_handshake = False - self._sent_non_empty_bytes = False - self._host = None self._port = None self._address = None @@ -187,9 +180,6 @@ def settimeout(self, timeout: float | None) -> None: def getsockopt(level: int, optname: int, buflen: int | None = None) -> int: return socket.SOCK_STREAM - def do_handshake(self) -> None: - self._did_handshake = True - def getpeername(self) -> _RetAddress: return self._address @@ -202,32 +192,6 @@ def getblocking(self) -> bool: def getsockname(self) -> _RetAddress: return true_gethostbyname(self._address[0]), self._address[1] - def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: - if not (self._host and self._port): - self._address = self._host, self._port = Mocket._address - - now = datetime.now() - shift = now + timedelta(days=30 * 12) - return { - "notAfter": shift.strftime("%b %d %H:%M:%S GMT"), - "subjectAltName": ( - ("DNS", f"*.{self._host}"), - ("DNS", self._host), - ("DNS", "*"), - ), - "subject": ( - (("organizationName", f"*.{self._host}"),), - (("organizationalUnitName", "Domain Control Validated"),), - (("commonName", f"*.{self._host}"),), - ), - } - - def unwrap(self) -> MocketSocket: - return self - - def write(self, data: bytes) -> int | None: - return self.send(encode_to_bytes(data)) - def connect(self, address: Address) -> None: self._address = self._host, self._port = address Mocket._address = address @@ -254,14 +218,6 @@ def sendall(self, data, entry=None, *args, **kwargs): self.io.truncate() self.io.seek(0) - def read(self, buffersize: int | None = None) -> bytes: - rv = self.io.read(buffersize) - if rv: - self._sent_non_empty_bytes = True - if self._did_handshake and not self._sent_non_empty_bytes: - raise ssl.SSLWantReadError("The operation did not complete (read)") - return rv - def recv_into( self, buffer: WriteableBuffer, @@ -344,14 +300,6 @@ def true_sendall(self, data: ReadableBuffer, *args: Any, **kwargs: Any) -> int: host, port = self._host, self._port host = true_gethostbyname(host) - if isinstance(self._true_socket, true_socket) and self._secure_socket: - from mocket.ssl.context import true_urllib3_ssl_wrap_socket - - self._true_socket = true_urllib3_ssl_wrap_socket( - self._true_socket, - **self._kwargs, - ) - with contextlib.suppress(OSError, ValueError): # already connected self._true_socket.connect((host, port)) diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index fccf5db4..e5f60c0a 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -7,6 +7,7 @@ import urllib3.util.ssl_ from mocket.socket import MocketSocket +from mocket.ssl.socket import MocketSSLSocket true_ssl_context = ssl.SSLContext @@ -70,10 +71,26 @@ def dummy_method(*args: Any, **kwargs: Any) -> Any: setattr(self, m, dummy_method) @staticmethod - def wrap_socket(sock: MocketSocket, *args: Any, **kwargs: Any) -> MocketSocket: - sock._kwargs = kwargs - sock._secure_socket = True - return sock + def wrap_socket(sock: MocketSocket, *args: Any, **kwargs: Any) -> MocketSSLSocket: + ssl_socket = MocketSSLSocket() + ssl_socket._original_socket = sock + + ssl_socket._true_socket = true_urllib3_ssl_wrap_socket( + sock._true_socket, + **kwargs, + ) + ssl_socket._kwargs = kwargs + + ssl_socket._timeout = sock._timeout + + ssl_socket._host = sock._host + ssl_socket._port = sock._port + ssl_socket._address = sock._address + + ssl_socket._io = sock._io + ssl_socket._entry = sock._entry + + return ssl_socket @staticmethod def wrap_bio( @@ -81,7 +98,7 @@ def wrap_bio( outgoing: Any, # _ssl.MemoryBIO server_side: bool = False, server_hostname: str | bytes | None = None, - ) -> MocketSocket: - ssl_obj = MocketSocket() + ) -> MocketSSLSocket: + ssl_obj = MocketSSLSocket() ssl_obj._host = server_hostname return ssl_obj diff --git a/mocket/ssl/socket.py b/mocket/ssl/socket.py new file mode 100644 index 00000000..e50b7320 --- /dev/null +++ b/mocket/ssl/socket.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import ssl +from datetime import datetime, timedelta +from typing import Any + +from mocket.compat import encode_to_bytes +from mocket.mocket import Mocket +from mocket.socket import MocketSocket +from mocket.types import _PeerCertRetDictType + + +class MocketSSLSocket(MocketSocket): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self._did_handshake = False + self._sent_non_empty_bytes = False + self._original_socket: MocketSocket = self + + def read(self, buffersize: int | None = None) -> bytes: + rv = self.io.read(buffersize) + if rv: + self._sent_non_empty_bytes = True + if self._did_handshake and not self._sent_non_empty_bytes: + raise ssl.SSLWantReadError("The operation did not complete (read)") + return rv + + def write(self, data: bytes) -> int | None: + return self.send(encode_to_bytes(data)) + + def do_handshake(self) -> None: + self._did_handshake = True + + def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: + if not (self._host and self._port): + self._address = self._host, self._port = Mocket._address + + now = datetime.now() + shift = now + timedelta(days=30 * 12) + return { + "notAfter": shift.strftime("%b %d %H:%M:%S GMT"), + "subjectAltName": ( + ("DNS", f"*.{self._host}"), + ("DNS", self._host), + ("DNS", "*"), + ), + "subject": ( + (("organizationName", f"*.{self._host}"),), + (("organizationalUnitName", "Domain Control Validated"),), + (("commonName", f"*.{self._host}"),), + ), + } + + def ciper(self) -> tuple[str, str, str]: + return ("ADH", "AES256", "SHA") + + def compression(self) -> str | None: + return ssl.OP_NO_COMPRESSION + + def unwrap(self) -> MocketSocket: + return self._original_socket From 14478af8c638a3947bfc6eb9c5f41a74af23c95c Mon Sep 17 00:00:00 2001 From: betaboon Date: Wed, 20 Nov 2024 10:53:36 +0100 Subject: [PATCH 38/98] Refactor rename ssl classes (#266) * refactor: rename MocketSocketCore to MocketSocketIO * refactor: rename FakeSSLContext to MocketSSLContext --- mocket/__init__.py | 6 +++++- mocket/inject.py | 14 +++++++------- mocket/io.py | 2 +- mocket/plugins/aiohttp_connector.py | 6 +++--- mocket/socket.py | 8 ++++---- mocket/ssl/context.py | 4 ++-- mocket/utils.py | 2 +- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 58993a24..53064434 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -2,7 +2,10 @@ from mocket.entry import MocketEntry from mocket.mocket import Mocket from mocket.mocketizer import Mocketizer, mocketize -from mocket.ssl.context import FakeSSLContext +from mocket.ssl.context import MocketSSLContext + +# NOTE this is here for backwards-compat to keep old import-paths working +from mocket.ssl.context import MocketSSLContext as FakeSSLContext __all__ = ( "async_mocketize", @@ -10,6 +13,7 @@ "Mocket", "MocketEntry", "Mocketizer", + "MocketSSLContext", "FakeSSLContext", ) diff --git a/mocket/inject.py b/mocket/inject.py index b733dd3c..35e9da01 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -29,7 +29,7 @@ def enable( mock_socketpair, mock_urllib3_match_hostname, ) - from mocket.ssl.context import FakeSSLContext + from mocket.ssl.context import MocketSSLContext Mocket._namespace = namespace Mocket._truesocket_recording_dir = truesocket_recording_dir @@ -48,21 +48,21 @@ def enable( socket.gethostbyname = socket.__dict__["gethostbyname"] = mock_gethostbyname socket.getaddrinfo = socket.__dict__["getaddrinfo"] = mock_getaddrinfo socket.socketpair = socket.__dict__["socketpair"] = mock_socketpair - ssl.wrap_socket = ssl.__dict__["wrap_socket"] = FakeSSLContext.wrap_socket - ssl.SSLContext = ssl.__dict__["SSLContext"] = FakeSSLContext + ssl.wrap_socket = ssl.__dict__["wrap_socket"] = MocketSSLContext.wrap_socket + ssl.SSLContext = ssl.__dict__["SSLContext"] = MocketSSLContext socket.inet_pton = socket.__dict__["inet_pton"] = mock_inet_pton urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( - FakeSSLContext.wrap_socket + MocketSSLContext.wrap_socket ) urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ "ssl_wrap_socket" - ] = FakeSSLContext.wrap_socket + ] = MocketSSLContext.wrap_socket urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( - FakeSSLContext.wrap_socket + MocketSSLContext.wrap_socket ) urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ "ssl_wrap_socket" - ] = FakeSSLContext.wrap_socket + ] = MocketSSLContext.wrap_socket urllib3.connection.match_hostname = urllib3.connection.__dict__[ "match_hostname" ] = mock_urllib3_match_hostname diff --git a/mocket/io.py b/mocket/io.py index 648b16dd..0334410b 100644 --- a/mocket/io.py +++ b/mocket/io.py @@ -4,7 +4,7 @@ from mocket.mocket import Mocket -class MocketSocketCore(io.BytesIO): +class MocketSocketIO(io.BytesIO): def __init__(self, address) -> None: self._address = address super().__init__() diff --git a/mocket/plugins/aiohttp_connector.py b/mocket/plugins/aiohttp_connector.py index 353c3af7..cde5019a 100644 --- a/mocket/plugins/aiohttp_connector.py +++ b/mocket/plugins/aiohttp_connector.py @@ -1,6 +1,6 @@ import contextlib -from mocket import FakeSSLContext +from mocket import MocketSSLContext with contextlib.suppress(ModuleNotFoundError): from aiohttp import ClientRequest @@ -14,5 +14,5 @@ class MocketTCPConnector(TCPConnector): slightly patching the `ClientSession` while testing. """ - def _get_ssl_context(self, req: ClientRequest) -> FakeSSLContext: - return FakeSSLContext() + def _get_ssl_context(self, req: ClientRequest) -> MocketSSLContext: + return MocketSSLContext() diff --git a/mocket/socket.py b/mocket/socket.py index 2ce74c09..e79c86c8 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -18,7 +18,7 @@ from mocket.compat import decode_from_bytes, encode_to_bytes from mocket.entry import MocketEntry -from mocket.io import MocketSocketCore +from mocket.io import MocketSocketIO from mocket.mocket import Mocket from mocket.mode import MocketMode from mocket.types import ( @@ -148,9 +148,9 @@ def proto(self) -> int: return self._proto @property - def io(self) -> MocketSocketCore: + def io(self) -> MocketSocketIO: if self._io is None: - self._io = MocketSocketCore((self._host, self._port)) + self._io = MocketSocketIO((self._host, self._port)) return self._io def fileno(self) -> int: @@ -196,7 +196,7 @@ def connect(self, address: Address) -> None: self._address = self._host, self._port = address Mocket._address = address - def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketCore: + def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketIO: return self.io def get_entry(self, data: bytes) -> MocketEntry | None: diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index e5f60c0a..438faa10 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -27,7 +27,7 @@ true_urllib3_wrap_socket = urllib3_wrap_socket -class SuperFakeSSLContext: +class _MocketSSLContext: """For Python 3.6 and newer.""" class FakeSetter(int): @@ -40,7 +40,7 @@ def __set__(self, *args: Any) -> None: verify_flags = FakeSetter() -class FakeSSLContext(SuperFakeSSLContext): +class MocketSSLContext(_MocketSSLContext): DUMMY_METHODS = ( "load_default_certs", "load_verify_locations", diff --git a/mocket/utils.py b/mocket/utils.py index f94b78f7..52777687 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -7,7 +7,7 @@ from mocket.compat import decode_from_bytes, encode_to_bytes # NOTE this is here for backwards-compat to keep old import-paths working -from mocket.io import MocketSocketCore as MocketSocketCore +from mocket.io import MocketSocketIO as MocketSocketCore # NOTE this is here for backwards-compat to keep old import-paths working from mocket.mode import MocketMode as MocketMode From 0da27224ad800297c4d120e740e1ba263da0327a Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 20 Nov 2024 11:02:33 +0100 Subject: [PATCH 39/98] Changes from `ruff`. (#267) --- .pre-commit-config.yaml | 4 ++-- mocket/socket.py | 3 --- mocket/utils.py | 12 +++++++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eae15d12..74e4cdae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: forbid-crlf - id: remove-crlf - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -15,7 +15,7 @@ repos: exclude: helm/ args: [ --unsafe ] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.4.4" + rev: "v0.7.4" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/mocket/socket.py b/mocket/socket.py index e79c86c8..03c6f7e5 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -7,8 +7,6 @@ import os import select import socket -import ssl -from datetime import datetime, timedelta from json.decoder import JSONDecodeError from types import TracebackType from typing import Any, Type @@ -25,7 +23,6 @@ Address, ReadableBuffer, WriteableBuffer, - _PeerCertRetDictType, _RetAddress, ) from mocket.utils import hexdump, hexload diff --git a/mocket/utils.py b/mocket/utils.py index 52777687..b9e2c259 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -10,7 +10,7 @@ from mocket.io import MocketSocketIO as MocketSocketCore # NOTE this is here for backwards-compat to keep old import-paths working -from mocket.mode import MocketMode as MocketMode +from mocket.mode import MocketMode SSL_PROTOCOL = ssl.PROTOCOL_TLSv1_2 @@ -42,3 +42,13 @@ def get_mocketize(wrapper_: Callable) -> Callable: wrapper_, kwsyntax=True, ) + + +__all__ = ( + "MocketSocketCore", + "MocketMode", + "SSL_PROTOCOL", + "hexdump", + "hexload", + "get_mocketize", +) From a5b5e34b8f981e033bf9694f4fec53481d933ade Mon Sep 17 00:00:00 2001 From: betaboon Date: Mon, 25 Nov 2024 11:57:24 +0100 Subject: [PATCH 40/98] improve injection code, make backwards compat explicit, make ssl-api explicit (#268) * refactor: make injection code more readable and make backwards-compat explicit * refactor: move ssl socket-wrapping code to ssl/socket.py * refactor: convert MocketSSLContext.wrap_socket and wrap_bio to instance-methods * refactor: MocketSSLSocket use proper ssl-context instead of urllib3 --- mocket/inject.py | 149 +++++++++++++++++------------------------- mocket/socket.py | 11 ---- mocket/ssl/context.py | 60 +++++------------ mocket/ssl/socket.py | 32 +++++++++ mocket/urllib3.py | 20 ++++++ mocket/utils.py | 4 +- tests/test_mode.py | 2 +- 7 files changed, 132 insertions(+), 146 deletions(-) create mode 100644 mocket/urllib3.py diff --git a/mocket/inject.py b/mocket/inject.py index 35e9da01..469ab30b 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -1,24 +1,32 @@ from __future__ import annotations +import contextlib import os import socket import ssl +from types import ModuleType +from typing import Any import urllib3 -try: # pragma: no cover - from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 +_patches_restore: dict[tuple[ModuleType, str], Any] = {} - pyopenssl_override = True -except ImportError: - pyopenssl_override = False + +def _patch(module: ModuleType, name: str, patched_value: Any) -> None: + with contextlib.suppress(KeyError): + original_value, module.__dict__[name] = module.__dict__[name], patched_value + _patches_restore[(module, name)] = original_value + + +def _restore(module: ModuleType, name: str) -> None: + if original_value := _patches_restore.pop((module, name)): + module.__dict__[name] = original_value def enable( namespace: str | None = None, truesocket_recording_dir: str | None = None, ) -> None: - from mocket.mocket import Mocket from mocket.socket import ( MocketSocket, mock_create_connection, @@ -27,99 +35,62 @@ def enable( mock_gethostname, mock_inet_pton, mock_socketpair, - mock_urllib3_match_hostname, ) - from mocket.ssl.context import MocketSSLContext + from mocket.ssl.context import MocketSSLContext, mock_wrap_socket + from mocket.urllib3 import ( + mock_match_hostname as mock_urllib3_match_hostname, + ) + from mocket.urllib3 import ( + mock_ssl_wrap_socket as mock_urllib3_ssl_wrap_socket, + ) + + patches = { + # stdlib: socket + (socket, "socket"): MocketSocket, + (socket, "create_connection"): mock_create_connection, + (socket, "getaddrinfo"): mock_getaddrinfo, + (socket, "gethostbyname"): mock_gethostbyname, + (socket, "gethostname"): mock_gethostname, + (socket, "inet_pton"): mock_inet_pton, + (socket, "SocketType"): MocketSocket, + (socket, "socketpair"): mock_socketpair, + # stdlib: ssl + (ssl, "SSLContext"): MocketSSLContext, + (ssl, "wrap_socket"): mock_wrap_socket, # python < 3.12.0 + # urllib3 + (urllib3.connection, "match_hostname"): mock_urllib3_match_hostname, + (urllib3.connection, "ssl_wrap_socket"): mock_urllib3_ssl_wrap_socket, + (urllib3.util, "ssl_wrap_socket"): mock_urllib3_ssl_wrap_socket, + (urllib3.util.ssl_, "ssl_wrap_socket"): mock_urllib3_ssl_wrap_socket, + (urllib3.util.ssl_, "wrap_socket"): mock_urllib3_ssl_wrap_socket, # urllib3 < 2 + } + + for (module, name), new_value in patches.items(): + _patch(module, name, new_value) + + with contextlib.suppress(ImportError): + from urllib3.contrib.pyopenssl import extract_from_urllib3 + + extract_from_urllib3() + + from mocket.mocket import Mocket Mocket._namespace = namespace Mocket._truesocket_recording_dir = truesocket_recording_dir - if truesocket_recording_dir and not os.path.isdir(truesocket_recording_dir): # JSON dumps will be saved here raise AssertionError - socket.socket = socket.__dict__["socket"] = MocketSocket - socket._socketobject = socket.__dict__["_socketobject"] = MocketSocket - socket.SocketType = socket.__dict__["SocketType"] = MocketSocket - socket.create_connection = socket.__dict__["create_connection"] = ( - mock_create_connection - ) - socket.gethostname = socket.__dict__["gethostname"] = mock_gethostname - socket.gethostbyname = socket.__dict__["gethostbyname"] = mock_gethostbyname - socket.getaddrinfo = socket.__dict__["getaddrinfo"] = mock_getaddrinfo - socket.socketpair = socket.__dict__["socketpair"] = mock_socketpair - ssl.wrap_socket = ssl.__dict__["wrap_socket"] = MocketSSLContext.wrap_socket - ssl.SSLContext = ssl.__dict__["SSLContext"] = MocketSSLContext - socket.inet_pton = socket.__dict__["inet_pton"] = mock_inet_pton - urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( - MocketSSLContext.wrap_socket - ) - urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ - "ssl_wrap_socket" - ] = MocketSSLContext.wrap_socket - urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( - MocketSSLContext.wrap_socket - ) - urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ - "ssl_wrap_socket" - ] = MocketSSLContext.wrap_socket - urllib3.connection.match_hostname = urllib3.connection.__dict__[ - "match_hostname" - ] = mock_urllib3_match_hostname - if pyopenssl_override: # pragma: no cover - # Take out the pyopenssl version - use the default implementation - extract_from_urllib3() - def disable() -> None: + for module, name in list(_patches_restore.keys()): + _restore(module, name) + + with contextlib.suppress(ImportError): + from urllib3.contrib.pyopenssl import inject_into_urllib3 + + inject_into_urllib3() + from mocket.mocket import Mocket - from mocket.socket import ( - true_create_connection, - true_getaddrinfo, - true_gethostbyname, - true_gethostname, - true_inet_pton, - true_socket, - true_socketpair, - true_urllib3_match_hostname, - ) - from mocket.ssl.context import ( - true_ssl_context, - true_ssl_wrap_socket, - true_urllib3_ssl_wrap_socket, - true_urllib3_wrap_socket, - ) - socket.socket = socket.__dict__["socket"] = true_socket - socket._socketobject = socket.__dict__["_socketobject"] = true_socket - socket.SocketType = socket.__dict__["SocketType"] = true_socket - socket.create_connection = socket.__dict__["create_connection"] = ( - true_create_connection - ) - socket.gethostname = socket.__dict__["gethostname"] = true_gethostname - socket.gethostbyname = socket.__dict__["gethostbyname"] = true_gethostbyname - socket.getaddrinfo = socket.__dict__["getaddrinfo"] = true_getaddrinfo - socket.socketpair = socket.__dict__["socketpair"] = true_socketpair - if true_ssl_wrap_socket: - ssl.wrap_socket = ssl.__dict__["wrap_socket"] = true_ssl_wrap_socket - ssl.SSLContext = ssl.__dict__["SSLContext"] = true_ssl_context - socket.inet_pton = socket.__dict__["inet_pton"] = true_inet_pton - urllib3.util.ssl_.wrap_socket = urllib3.util.ssl_.__dict__["wrap_socket"] = ( - true_urllib3_wrap_socket - ) - urllib3.util.ssl_.ssl_wrap_socket = urllib3.util.ssl_.__dict__[ - "ssl_wrap_socket" - ] = true_urllib3_ssl_wrap_socket - urllib3.util.ssl_wrap_socket = urllib3.util.__dict__["ssl_wrap_socket"] = ( - true_urllib3_ssl_wrap_socket - ) - urllib3.connection.ssl_wrap_socket = urllib3.connection.__dict__[ - "ssl_wrap_socket" - ] = true_urllib3_ssl_wrap_socket - urllib3.connection.match_hostname = urllib3.connection.__dict__[ - "match_hostname" - ] = true_urllib3_match_hostname Mocket.reset() - if pyopenssl_override: # pragma: no cover - # Put the pyopenssl version back in place - inject_into_urllib3() diff --git a/mocket/socket.py b/mocket/socket.py index 03c6f7e5..9480d365 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -11,7 +11,6 @@ from types import TracebackType from typing import Any, Type -import urllib3.connection from typing_extensions import Self from mocket.compat import decode_from_bytes, encode_to_bytes @@ -27,14 +26,8 @@ ) from mocket.utils import hexdump, hexload -true_create_connection = socket.create_connection -true_getaddrinfo = socket.getaddrinfo true_gethostbyname = socket.gethostbyname -true_gethostname = socket.gethostname -true_inet_pton = socket.inet_pton true_socket = socket.socket -true_socketpair = socket.socketpair -true_urllib3_match_hostname = urllib3.connection.match_hostname xxh32 = None @@ -84,10 +77,6 @@ def mock_socketpair(*args, **kwargs): return _socket.socketpair(*args, **kwargs) -def mock_urllib3_match_hostname(*args: Any) -> None: - return None - - def _hash_request(h, req): return h(encode_to_bytes("".join(sorted(req.split("\r\n"))))).hexdigest() diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index 438faa10..6d5e7307 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -1,31 +1,10 @@ from __future__ import annotations -import contextlib -import ssl from typing import Any -import urllib3.util.ssl_ - from mocket.socket import MocketSocket from mocket.ssl.socket import MocketSSLSocket -true_ssl_context = ssl.SSLContext - -true_ssl_wrap_socket = None -true_urllib3_ssl_wrap_socket = urllib3.util.ssl_.ssl_wrap_socket -true_urllib3_wrap_socket = None - -with contextlib.suppress(ImportError): - # from Py3.12 it's only under SSLContext - from ssl import wrap_socket as ssl_wrap_socket - - true_ssl_wrap_socket = ssl_wrap_socket - -with contextlib.suppress(ImportError): - from urllib3.util.ssl_ import wrap_socket as urllib3_wrap_socket - - true_urllib3_wrap_socket = urllib3_wrap_socket - class _MocketSSLContext: """For Python 3.6 and newer.""" @@ -70,30 +49,16 @@ def dummy_method(*args: Any, **kwargs: Any) -> Any: for m in self.DUMMY_METHODS: setattr(self, m, dummy_method) - @staticmethod - def wrap_socket(sock: MocketSocket, *args: Any, **kwargs: Any) -> MocketSSLSocket: - ssl_socket = MocketSSLSocket() - ssl_socket._original_socket = sock - - ssl_socket._true_socket = true_urllib3_ssl_wrap_socket( - sock._true_socket, - **kwargs, - ) - ssl_socket._kwargs = kwargs - - ssl_socket._timeout = sock._timeout - - ssl_socket._host = sock._host - ssl_socket._port = sock._port - ssl_socket._address = sock._address - - ssl_socket._io = sock._io - ssl_socket._entry = sock._entry - - return ssl_socket + def wrap_socket( + self, + sock: MocketSocket, + *args: Any, + **kwargs: Any, + ) -> MocketSSLSocket: + return MocketSSLSocket._create(sock, *args, **kwargs) - @staticmethod def wrap_bio( + self, incoming: Any, # _ssl.MemoryBIO outgoing: Any, # _ssl.MemoryBIO server_side: bool = False, @@ -102,3 +67,12 @@ def wrap_bio( ssl_obj = MocketSSLSocket() ssl_obj._host = server_hostname return ssl_obj + + +def mock_wrap_socket( + sock: MocketSocket, + *args: Any, + **kwargs: Any, +) -> MocketSSLSocket: + context = MocketSSLContext() + return context.wrap_socket(sock, *args, **kwargs) diff --git a/mocket/ssl/socket.py b/mocket/ssl/socket.py index e50b7320..f7f41761 100644 --- a/mocket/ssl/socket.py +++ b/mocket/ssl/socket.py @@ -60,3 +60,35 @@ def compression(self) -> str | None: def unwrap(self) -> MocketSocket: return self._original_socket + + @classmethod + def _create( + cls, + sock: MocketSocket, + ssl_context: ssl.SSLContext | None = None, + server_hostname: str | None = None, + *args: Any, + **kwargs: Any, + ) -> MocketSSLSocket: + ssl_socket = MocketSSLSocket() + ssl_socket._original_socket = sock + ssl_socket._true_socket = sock._true_socket + + if ssl_context: + ssl_socket._true_socket = ssl_context.wrap_socket( + sock=ssl_socket._true_socket, + server_hostname=server_hostname, + ) + + ssl_socket._kwargs = kwargs + + ssl_socket._timeout = sock._timeout + + ssl_socket._host = sock._host + ssl_socket._port = sock._port + ssl_socket._address = sock._address + + ssl_socket._io = sock._io + ssl_socket._entry = sock._entry + + return ssl_socket diff --git a/mocket/urllib3.py b/mocket/urllib3.py new file mode 100644 index 00000000..e89bc7b5 --- /dev/null +++ b/mocket/urllib3.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +from mocket.socket import MocketSocket +from mocket.ssl.context import MocketSSLContext +from mocket.ssl.socket import MocketSSLSocket + + +def mock_match_hostname(*args: Any) -> None: + return None + + +def mock_ssl_wrap_socket( + sock: MocketSocket, + *args: Any, + **kwargs: Any, +) -> MocketSSLSocket: + context = MocketSSLContext() + return context.wrap_socket(sock, *args, **kwargs) diff --git a/mocket/utils.py b/mocket/utils.py index b9e2c259..59403954 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -45,10 +45,10 @@ def get_mocketize(wrapper_: Callable) -> Callable: __all__ = ( - "MocketSocketCore", "MocketMode", + "MocketSocketCore", "SSL_PROTOCOL", + "get_mocketize", "hexdump", "hexload", - "get_mocketize", ) diff --git a/tests/test_mode.py b/tests/test_mode.py index 2a764949..ea5905b0 100644 --- a/tests/test_mode.py +++ b/tests/test_mode.py @@ -4,7 +4,7 @@ from mocket import Mocketizer, mocketize from mocket.exceptions import StrictMocketException from mocket.mockhttp import Entry, Response -from mocket.utils import MocketMode +from mocket.mode import MocketMode @mocketize(strict_mode=True) From e529319e1502b367843ab6364795c91aa096be2e Mon Sep 17 00:00:00 2001 From: betaboon Date: Tue, 26 Nov 2024 11:09:19 +0100 Subject: [PATCH 41/98] Refactor introduce recording storage (#274) * refactor: separate injection and enable/disable logic * refactor: add class that handles request records --- .github/workflows/main.yml | 2 +- mocket/inject.py | 18 +---- mocket/mocket.py | 46 ++++++++++-- mocket/recording.py | 147 +++++++++++++++++++++++++++++++++++++ mocket/socket.py | 134 ++++++++++----------------------- mocket/utils.py | 13 +--- 6 files changed, 230 insertions(+), 130 deletions(-) create mode 100644 mocket/recording.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c4481efc..cdb55fe0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', ' 3.13', 'pypy3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.10'] steps: - uses: actions/checkout@v4 diff --git a/mocket/inject.py b/mocket/inject.py index 469ab30b..866ee563 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import os import socket import ssl from types import ModuleType @@ -23,10 +22,7 @@ def _restore(module: ModuleType, name: str) -> None: module.__dict__[name] = original_value -def enable( - namespace: str | None = None, - truesocket_recording_dir: str | None = None, -) -> None: +def enable() -> None: from mocket.socket import ( MocketSocket, mock_create_connection, @@ -73,14 +69,6 @@ def enable( extract_from_urllib3() - from mocket.mocket import Mocket - - Mocket._namespace = namespace - Mocket._truesocket_recording_dir = truesocket_recording_dir - if truesocket_recording_dir and not os.path.isdir(truesocket_recording_dir): - # JSON dumps will be saved here - raise AssertionError - def disable() -> None: for module, name in list(_patches_restore.keys()): @@ -90,7 +78,3 @@ def disable() -> None: from urllib3.contrib.pyopenssl import inject_into_urllib3 inject_into_urllib3() - - from mocket.mocket import Mocket - - Mocket.reset() diff --git a/mocket/mocket.py b/mocket/mocket.py index 3476902d..a01a7b46 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -3,9 +3,11 @@ import collections import itertools import os +from pathlib import Path from typing import TYPE_CHECKING, ClassVar import mocket.inject +from mocket.recording import MocketRecordStorage # NOTE this is here for backwards-compat to keep old import-paths working # from mocket.socket import MocketSocket as MocketSocket @@ -20,11 +22,36 @@ class Mocket: _address: ClassVar[Address] = (None, None) _entries: ClassVar[dict[Address, list[MocketEntry]]] = collections.defaultdict(list) _requests: ClassVar[list] = [] - _namespace: ClassVar[str] = str(id(_entries)) - _truesocket_recording_dir: ClassVar[str | None] = None + _record_storage: ClassVar[MocketRecordStorage | None] = None - enable = mocket.inject.enable - disable = mocket.inject.disable + @classmethod + def enable( + cls, + namespace: str | None = None, + truesocket_recording_dir: str | None = None, + ) -> None: + if namespace is None: + namespace = str(id(cls._entries)) + + if truesocket_recording_dir is not None: + recording_dir = Path(truesocket_recording_dir) + + if not recording_dir.is_dir(): + # JSON dumps will be saved here + raise AssertionError + + cls._record_storage = MocketRecordStorage( + directory=recording_dir, + namespace=namespace, + ) + + mocket.inject.enable() + + @classmethod + def disable(cls) -> None: + cls.reset() + + mocket.inject.disable() @classmethod def get_pair(cls, address: Address) -> tuple[int, int] | tuple[None, None]: @@ -69,6 +96,7 @@ def reset(cls) -> None: cls._socket_pairs = {} cls._entries = collections.defaultdict(list) cls._requests = [] + cls._record_storage = None @classmethod def last_request(cls): @@ -89,12 +117,16 @@ def has_requests(cls) -> bool: return bool(cls.request_list()) @classmethod - def get_namespace(cls) -> str: - return cls._namespace + def get_namespace(cls) -> str | None: + if not cls._record_storage: + return None + return cls._record_storage.namespace @classmethod def get_truesocket_recording_dir(cls) -> str | None: - return cls._truesocket_recording_dir + if not cls._record_storage: + return None + return str(cls._record_storage.directory) @classmethod def assert_fail_if_entries_not_served(cls) -> None: diff --git a/mocket/recording.py b/mocket/recording.py new file mode 100644 index 00000000..97d2adbe --- /dev/null +++ b/mocket/recording.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import contextlib +import hashlib +import json +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + +from mocket.compat import decode_from_bytes, encode_to_bytes +from mocket.types import Address +from mocket.utils import hexdump, hexload + +hash_function = hashlib.md5 + +with contextlib.suppress(ImportError): + from xxhash_cffi import xxh32 as xxhash_cffi_xxh32 + + hash_function = xxhash_cffi_xxh32 + +with contextlib.suppress(ImportError): + from xxhash import xxh32 as xxhash_xxh32 + + hash_function = xxhash_xxh32 + + +def _hash_prepare_request(data: bytes) -> bytes: + _data = decode_from_bytes(data) + return encode_to_bytes("".join(sorted(_data.split("\r\n")))) + + +def _hash_request(data: bytes) -> str: + _data = _hash_prepare_request(data) + return hash_function(_data).hexdigest() + + +def _hash_request_fallback(data: bytes) -> str: + _data = _hash_prepare_request(data) + return hashlib.md5(_data).hexdigest() + + +@dataclass +class MocketRecord: + host: str + port: int + request: bytes + response: bytes + + +class MocketRecordStorage: + def __init__(self, directory: Path, namespace: str) -> None: + self._directory = directory + self._namespace = namespace + self._records: defaultdict[Address, defaultdict[str, MocketRecord]] = ( + defaultdict(defaultdict) + ) + + self._load() + + @property + def directory(self) -> Path: + return self._directory + + @property + def namespace(self) -> str: + return self._namespace + + @property + def file(self) -> Path: + return self._directory / f"{self._namespace}.json" + + def _load(self) -> None: + if not self.file.exists(): + return + + json_data = self.file.read_text() + records = json.loads(json_data) + for host, port_signature_record in records.items(): + for port, signature_record in port_signature_record.items(): + for signature, record in signature_record.items(): + # NOTE backward-compat + try: + request_data = hexload(record["request"]) + except ValueError: + request_data = record["request"] + + self._records[(host, int(port))][signature] = MocketRecord( + host=host, + port=port, + request=request_data, + response=hexload(record["response"]), + ) + + def _save(self) -> None: + data: dict[str, dict[str, dict[str, dict[str, str]]]] = defaultdict( + lambda: defaultdict(defaultdict) + ) + for address, signature_record in self._records.items(): + host, port = address + for signature, record in signature_record.items(): + data[host][str(port)][signature] = dict( + request=decode_from_bytes(record.request), + response=hexdump(record.response), + ) + + json_data = json.dumps(data, indent=4, sort_keys=True) + self.file.parent.mkdir(exist_ok=True) + self.file.write_text(json_data) + + def get_records(self, address: Address) -> list[MocketRecord]: + return list(self._records[address].values()) + + def get_record(self, address: Address, request: bytes) -> MocketRecord | None: + # NOTE for backward-compat + request_signature_fallback = _hash_request_fallback(request) + if request_signature_fallback in self._records[address]: + return self._records[address].get(request_signature_fallback) + + request_signature = _hash_request(request) + if request_signature in self._records[address]: + return self._records[address][request_signature] + + return None + + def put_record( + self, + address: Address, + request: bytes, + response: bytes, + ) -> None: + host, port = address + record = MocketRecord( + host=host, + port=port, + request=request, + response=response, + ) + + # NOTE for backward-compat + request_signature_fallback = _hash_request_fallback(request) + if request_signature_fallback in self._records[address]: + self._records[address][request_signature_fallback] = record + return + + request_signature = _hash_request(request) + self._records[address][request_signature] = record + self._save() diff --git a/mocket/socket.py b/mocket/socket.py index 9480d365..3b1862e2 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -2,18 +2,14 @@ import contextlib import errno -import hashlib -import json import os import select import socket -from json.decoder import JSONDecodeError from types import TracebackType from typing import Any, Type from typing_extensions import Self -from mocket.compat import decode_from_bytes, encode_to_bytes from mocket.entry import MocketEntry from mocket.io import MocketSocketIO from mocket.mocket import Mocket @@ -24,21 +20,11 @@ WriteableBuffer, _RetAddress, ) -from mocket.utils import hexdump, hexload true_gethostbyname = socket.gethostbyname true_socket = socket.socket -xxh32 = None -try: - from xxhash import xxh32 -except ImportError: # pragma: no cover - with contextlib.suppress(ImportError): - from xxhash_cffi import xxh32 -hasher = xxh32 or hashlib.md5 - - def mock_create_connection(address, timeout=None, source_address=None): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) if timeout: @@ -77,10 +63,6 @@ def mock_socketpair(*args, **kwargs): return _socket.socketpair(*args, **kwargs) -def _hash_request(h, req): - return h(encode_to_bytes("".join(sorted(req.split("\r\n"))))).hexdigest() - - class MocketSocket: def __init__( self, @@ -235,87 +217,47 @@ def recv(self, buffersize: int, flags: int | None = None) -> bytes: exc.args = (0,) raise exc - def true_sendall(self, data: ReadableBuffer, *args: Any, **kwargs: Any) -> int: - if not MocketMode().is_allowed((self._host, self._port)): + def true_sendall(self, data: bytes, *args: Any, **kwargs: Any) -> bytes: + if not MocketMode().is_allowed(self._address): MocketMode.raise_not_allowed() - req = decode_from_bytes(data) - # make request unique again - req_signature = _hash_request(hasher, req) - # port should be always a string - port = str(self._port) - - # prepare responses dictionary - responses = {} - - if Mocket.get_truesocket_recording_dir(): - path = os.path.join( - Mocket.get_truesocket_recording_dir(), - Mocket.get_namespace() + ".json", + # try to get the response from recordings + if Mocket._record_storage: + record = Mocket._record_storage.get_record( + address=self._address, + request=data, ) - # check if there's already a recorded session dumped to a JSON file - try: - with open(path) as f: - responses = json.load(f) - # if not, create a new dictionary - except (FileNotFoundError, JSONDecodeError): - pass - - try: - try: - response_dict = responses[self._host][port][req_signature] - except KeyError: - if hasher is not hashlib.md5: - # Fallback for backwards compatibility - req_signature = _hash_request(hashlib.md5, req) - response_dict = responses[self._host][port][req_signature] - else: - raise - except KeyError: - # preventing next KeyError exceptions - responses.setdefault(self._host, {}) - responses[self._host].setdefault(port, {}) - responses[self._host][port].setdefault(req_signature, {}) - response_dict = responses[self._host][port][req_signature] - - # try to get the response from the dictionary - try: - encoded_response = hexload(response_dict["response"]) - # if not available, call the real sendall - except KeyError: - host, port = self._host, self._port - host = true_gethostbyname(host) - - with contextlib.suppress(OSError, ValueError): - # already connected - self._true_socket.connect((host, port)) - self._true_socket.sendall(data, *args, **kwargs) - encoded_response = b"" - # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 - while True: - more_to_read = select.select([self._true_socket], [], [], 0.1)[0] - if not more_to_read and encoded_response: - break - new_content = self._true_socket.recv(self._buflen) - if not new_content: - break - encoded_response += new_content - - # dump the resulting dictionary to a JSON file - if Mocket.get_truesocket_recording_dir(): - # update the dictionary with request and response lines - response_dict["request"] = req - response_dict["response"] = hexdump(encoded_response) - - with open(path, mode="w") as f: - f.write( - decode_from_bytes( - json.dumps(responses, indent=4, sort_keys=True) - ) - ) - - # response back to .sendall() which writes it to the Mocket socket and flush the BytesIO - return encoded_response + if record is not None: + return record.response + + host, port = self._address + host = true_gethostbyname(host) + + with contextlib.suppress(OSError, ValueError): + # already connected + self._true_socket.connect((host, port)) + + self._true_socket.sendall(data, *args, **kwargs) + response = b"" + # https://github.com/kennethreitz/requests/blob/master/tests/testserver/server.py#L12 + while True: + more_to_read = select.select([self._true_socket], [], [], 0.1)[0] + if not more_to_read and response: + break + new_content = self._true_socket.recv(self._buflen) + if not new_content: + break + response += new_content + + # store request+response in recordings + if Mocket._record_storage: + Mocket._record_storage.put_record( + address=self._address, + request=data, + response=response, + ) + + return response def send( self, diff --git a/mocket/utils.py b/mocket/utils.py index 59403954..31557a58 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -6,12 +6,6 @@ from mocket.compat import decode_from_bytes, encode_to_bytes -# NOTE this is here for backwards-compat to keep old import-paths working -from mocket.io import MocketSocketIO as MocketSocketCore - -# NOTE this is here for backwards-compat to keep old import-paths working -from mocket.mode import MocketMode - SSL_PROTOCOL = ssl.PROTOCOL_TLSv1_2 @@ -30,7 +24,10 @@ def hexload(string: str) -> bytes: True """ string_no_spaces = "".join(string.split()) - return encode_to_bytes(binascii.unhexlify(string_no_spaces)) + try: + return encode_to_bytes(binascii.unhexlify(string_no_spaces)) + except binascii.Error as e: + raise ValueError from e def get_mocketize(wrapper_: Callable) -> Callable: @@ -45,8 +42,6 @@ def get_mocketize(wrapper_: Callable) -> Callable: __all__ = ( - "MocketMode", - "MocketSocketCore", "SSL_PROTOCOL", "get_mocketize", "hexdump", From 9cfad4cdb4fcec787114f0a9cb36afac274722fd Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 26 Nov 2024 12:20:12 +0100 Subject: [PATCH 42/98] Small cleanup (#275) * Small cleanup. --- mocket/ssl/socket.py | 5 +++-- mocket/utils.py | 4 ---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/mocket/ssl/socket.py b/mocket/ssl/socket.py index f7f41761..6dcd7817 100644 --- a/mocket/ssl/socket.py +++ b/mocket/ssl/socket.py @@ -2,6 +2,7 @@ import ssl from datetime import datetime, timedelta +from ssl import Options from typing import Any from mocket.compat import encode_to_bytes @@ -53,9 +54,9 @@ def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: } def ciper(self) -> tuple[str, str, str]: - return ("ADH", "AES256", "SHA") + return "ADH", "AES256", "SHA" - def compression(self) -> str | None: + def compression(self) -> Options: return ssl.OP_NO_COMPRESSION def unwrap(self) -> MocketSocket: diff --git a/mocket/utils.py b/mocket/utils.py index 31557a58..ab293776 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -1,13 +1,10 @@ from __future__ import annotations import binascii -import ssl from typing import Callable from mocket.compat import decode_from_bytes, encode_to_bytes -SSL_PROTOCOL = ssl.PROTOCOL_TLSv1_2 - def hexdump(binary_string: bytes) -> str: r""" @@ -42,7 +39,6 @@ def get_mocketize(wrapper_: Callable) -> Callable: __all__ = ( - "SSL_PROTOCOL", "get_mocketize", "hexdump", "hexload", From 895e299f174710bf1ea054ace4421e1dbd0aa3c1 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 21 Dec 2024 19:21:30 +0100 Subject: [PATCH 43/98] Target `make safetest` got broken (#273) * Fix `Makefile`. * Only running tests that don't use real sockets got broken, fixed by adding CIENT SETINFO commands --- Makefile | 4 ++-- tests/test_mocket.py | 1 + tests/test_redis.py | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 7ba0210e..3ab33d87 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,12 @@ types: test: types @echo "Running Python tests" uv pip uninstall pook || true - export VIRTUAL_ENV=.venv; .venv/bin/wait-for-it --service httpbin.local:443 --service localhost:6379 --timeout 5 -- .venv/bin/pytest + .venv/bin/wait-for-it --service httpbin.local:443 --service localhost:6379 --timeout 5 -- .venv/bin/pytest uv pip install pook && .venv/bin/pytest tests/test_pook.py && uv pip uninstall pook @echo "" safetest: - export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; make test + export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; .venv/bin/pytest publish: clean install-test-requirements uv run python3 -m build --sdist --wheel . diff --git a/tests/test_mocket.py b/tests/test_mocket.py index 8d09f170..8810a5b9 100644 --- a/tests/test_mocket.py +++ b/tests/test_mocket.py @@ -222,6 +222,7 @@ def test_patch( @pytest.mark.skipif(not psutil.POSIX, reason="Uses a POSIX-only API to test") +@pytest.mark.skipif('os.getenv("SKIP_TRUE_HTTP", False)') @pytest.mark.asyncio async def test_no_dangling_fds(): url = "http://httpbin.local/ip" diff --git a/tests/test_redis.py b/tests/test_redis.py index 50b9beac..fb6ec355 100644 --- a/tests/test_redis.py +++ b/tests/test_redis.py @@ -158,9 +158,11 @@ def setUp(self): self.rclient = redis.StrictRedis() def mocketize_setup(self): + Entry.register_response("CLIENT SETINFO LIB-NAME redis-py", OK) + Entry.register_response(f"CLIENT SETINFO LIB-VER {redis.__version__}", OK) Entry.register_response("FLUSHDB", OK) self.rclient.flushdb() - self.assertEqual(len(Mocket.request_list()), 1) + self.assertEqual(len(Mocket.request_list()), 3) Mocket.reset() @mocketize From 87f1dec8d513e6c47f842e3efd4802cfb2f6e4e2 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 21 Dec 2024 23:56:29 +0100 Subject: [PATCH 44/98] Small refactor for `Makefile`. --- Makefile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 3ab33d87..25af4e93 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ #!/usr/bin/make -f +VENV_PATH = .venv/bin + install-dev-requirements: curl -LsSf https://astral.sh/uv/install.sh | sh uv venv && uv pip install hatch @@ -25,18 +27,18 @@ develop: install-dev-requirements install-test-requirements types: @echo "Type checking Python files" - .venv/bin/mypy --pretty + $(VENV_PATH)/mypy --pretty @echo "" test: types @echo "Running Python tests" uv pip uninstall pook || true - .venv/bin/wait-for-it --service httpbin.local:443 --service localhost:6379 --timeout 5 -- .venv/bin/pytest - uv pip install pook && .venv/bin/pytest tests/test_pook.py && uv pip uninstall pook + $(VENV_PATH)/wait-for-it --service httpbin.local:443 --service localhost:6379 --timeout 5 -- $(VENV_PATH)/pytest + uv pip install pook && $(VENV_PATH)/pytest tests/test_pook.py && uv pip uninstall pook @echo "" safetest: - export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; .venv/bin/pytest + export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; $(VENV_PATH)/pytest publish: clean install-test-requirements uv run python3 -m build --sdist --wheel . From 815a20ff1f7a0d5dae771d81f7856b0fdf9ff7df Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 24 Dec 2024 10:03:27 +0100 Subject: [PATCH 45/98] Releasing `beta` version after all the refactors. --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 53064434..faac03e3 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -17,4 +17,4 @@ "FakeSSLContext", ) -__version__ = "3.13.2" +__version__ = "3.13.3b1" From 9461bd035e2fa10b3d9732a81057bff419ddf6a4 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 24 Dec 2024 12:08:00 +0100 Subject: [PATCH 46/98] Switching to build and publish through `uv`. --- Makefile | 4 ++-- pyproject.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 25af4e93..07d5d459 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,8 @@ safetest: export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; $(VENV_PATH)/pytest publish: clean install-test-requirements - uv run python3 -m build --sdist --wheel . - uv run twine upload --repository mocket dist/ + uv build --package mocket --sdist --wheel + uv publish clean: rm -rf .coverage *.egg-info dist/ requirements.txt uv.lock || true diff --git a/pyproject.toml b/pyproject.toml index 77d1f5d4..5418304e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ test = [ "httpx", "pipfile", "build", - "twine", "fastapi", "aiohttp", "wait-for-it", From db33579c850a1bc01de2ca7540fab227c08b3926 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 13 Feb 2025 20:27:45 +0100 Subject: [PATCH 47/98] Update README.rst --- README.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e68cbfd3..82bf6c37 100644 --- a/README.rst +++ b/README.rst @@ -24,9 +24,8 @@ A socket mock framework Outside GitHub ============== -Mocket packages are available for `Arch Linux`_, `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, and of course from `PyPI`_. +Mocket packages are available for `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, and of course from `PyPI`_. -.. _`Arch Linux`: https://archlinux.org/packages/extra/any/python-mocket/ .. _`openSUSE`: https://software.opensuse.org/search?baseproject=ALL&q=mocket .. _`NixOS`: https://search.nixos.org/packages?query=mocket .. _`ALT Linux`: https://packages.altlinux.org/en/sisyphus/srpms/python3-module-mocket/ From e847dd6c7c52b7f4b05e2b51b3c9afe9494b6a44 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 6 Mar 2025 12:27:35 +0100 Subject: [PATCH 48/98] Bump Ubuntu version to latest LTS --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cdb55fe0..bb976ac9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ concurrency: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.10'] From 71e47163c9390db0d74e24f32e4520fc82c91bb7 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 22 Mar 2025 08:46:46 +0100 Subject: [PATCH 49/98] Moving ready-made mocks under `mocket.mocks` and decorators under `mocket.decorators`. (#278) --- mocket/__init__.py | 17 +++++++++++++++-- mocket/decorators/__init__.py | 0 mocket/{ => decorators}/async_mocket.py | 2 +- mocket/{ => decorators}/mocketizer.py | 0 mocket/mocks/__init__.py | 0 mocket/{ => mocks}/mockhttp.py | 0 mocket/{ => mocks}/mockredis.py | 0 7 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 mocket/decorators/__init__.py rename mocket/{ => decorators}/async_mocket.py (89%) rename mocket/{ => decorators}/mocketizer.py (100%) create mode 100644 mocket/mocks/__init__.py rename mocket/{ => mocks}/mockhttp.py (100%) rename mocket/{ => mocks}/mockredis.py (100%) diff --git a/mocket/__init__.py b/mocket/__init__.py index faac03e3..2279bf19 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,12 +1,25 @@ -from mocket.async_mocket import async_mocketize +import importlib +import sys + +from mocket.decorators.async_mocket import async_mocketize +from mocket.decorators.mocketizer import Mocketizer, mocketize from mocket.entry import MocketEntry from mocket.mocket import Mocket -from mocket.mocketizer import Mocketizer, mocketize from mocket.ssl.context import MocketSSLContext # NOTE this is here for backwards-compat to keep old import-paths working from mocket.ssl.context import MocketSSLContext as FakeSSLContext +sys.modules["mocket.mockhttp"] = importlib.import_module("mocket.mocks.mockhttp") +sys.modules["mocket.mockredis"] = importlib.import_module("mocket.mocks.mockredis") +sys.modules["mocket.async_mocket"] = importlib.import_module( + "mocket.decorators.async_mocket" +) +sys.modules["mocket.mocketizer"] = importlib.import_module( + "mocket.decorators.mocketizer" +) + + __all__ = ( "async_mocketize", "mocketize", diff --git a/mocket/decorators/__init__.py b/mocket/decorators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mocket/async_mocket.py b/mocket/decorators/async_mocket.py similarity index 89% rename from mocket/async_mocket.py rename to mocket/decorators/async_mocket.py index 709d225f..40b763ae 100644 --- a/mocket/async_mocket.py +++ b/mocket/decorators/async_mocket.py @@ -1,4 +1,4 @@ -from mocket.mocketizer import Mocketizer +from mocket.decorators.mocketizer import Mocketizer from mocket.utils import get_mocketize diff --git a/mocket/mocketizer.py b/mocket/decorators/mocketizer.py similarity index 100% rename from mocket/mocketizer.py rename to mocket/decorators/mocketizer.py diff --git a/mocket/mocks/__init__.py b/mocket/mocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mocket/mockhttp.py b/mocket/mocks/mockhttp.py similarity index 100% rename from mocket/mockhttp.py rename to mocket/mocks/mockhttp.py diff --git a/mocket/mockredis.py b/mocket/mocks/mockredis.py similarity index 100% rename from mocket/mockredis.py rename to mocket/mocks/mockredis.py From a0743f4497aff8b223e04d1ae8a7cb9293d0165e Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 22 Mar 2025 08:56:56 +0100 Subject: [PATCH 50/98] Bump version. --- mocket/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 2279bf19..c785bba5 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -7,7 +7,8 @@ from mocket.mocket import Mocket from mocket.ssl.context import MocketSSLContext -# NOTE this is here for backwards-compat to keep old import-paths working +# NOTE the following lines are here for backwards-compatibility, +# to keep old import-paths working from mocket.ssl.context import MocketSSLContext as FakeSSLContext sys.modules["mocket.mockhttp"] = importlib.import_module("mocket.mocks.mockhttp") @@ -30,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.3b1" +__version__ = "3.13.3" From 31e4d4c30b9f03f951e1f3bf1f75c77f95fdfee5 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 22 Mar 2025 09:06:11 +0100 Subject: [PATCH 51/98] Bump pre-commit hooks. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74e4cdae..b7a1e1f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: exclude: helm/ args: [ --unsafe ] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.7.4" + rev: "v0.11.2" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From a2dd03b0588fdcee6b17e64537ec904beda27bc8 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 23 Mar 2025 21:52:22 +0100 Subject: [PATCH 52/98] It's 2025. :) (#281) --- LICENSE | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 2788c4b4..db612228 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2024 Giorgio Salluzzo and individual contributors. All rights reserved. +Copyright (c) 2017-2025 Giorgio Salluzzo and individual contributors. All rights reserved. Copyright (c) 2013-2017 Andrea de Marco, Giorgio Salluzzo and individual contributors. All rights reserved. diff --git a/Makefile b/Makefile index 07d5d459..ff6a32ec 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ test: types @echo "" safetest: - export SKIP_TRUE_REDIS=1; export SKIP_TRUE_HTTP=1; $(VENV_PATH)/pytest + SKIP_TRUE_REDIS=1 SKIP_TRUE_HTTP=1 $(VENV_PATH)/pytest publish: clean install-test-requirements uv build --package mocket --sdist --wheel From 1722f05efdaf5f56950a880197d7c91b80886dc0 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 23 Mar 2025 22:17:23 +0100 Subject: [PATCH 53/98] Update README.rst --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 82bf6c37..8b08200f 100644 --- a/README.rst +++ b/README.rst @@ -63,8 +63,8 @@ The starting point to understand how to use *Mocket* to write a custom mock is t As next step, you are invited to have a look at the implementation of both the mocks it provides: -- HTTP mock (similar to HTTPretty) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mockhttp.py -- Redis mock (basic implementation) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mockredis.py +- HTTP mock (similar to HTTPretty) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mocks/mockhttp.py +- Redis mock (basic implementation) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mocks/mockredis.py Please also have a look at the huge test suite: From ee0dde1333e39bb0376a8a443648b1776059ff4e Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 26 Mar 2025 12:51:34 +0100 Subject: [PATCH 54/98] Missing dependency (#283) * Missing dependency. --- mocket/__init__.py | 2 +- mocket/decorators/async_mocket.py | 2 +- mocket/decorators/mocketizer.py | 2 +- mocket/utils.py | 15 +++++++-------- pyproject.toml | 2 ++ 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index c785bba5..fc941ca5 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.3" +__version__ = "3.13.4" diff --git a/mocket/decorators/async_mocket.py b/mocket/decorators/async_mocket.py index 40b763ae..3839d5f1 100644 --- a/mocket/decorators/async_mocket.py +++ b/mocket/decorators/async_mocket.py @@ -16,7 +16,7 @@ async def wrapper( return await test(*args, **kwargs) -async_mocketize = get_mocketize(wrapper_=wrapper) +async_mocketize = get_mocketize(wrapper) __all__ = ("async_mocketize",) diff --git a/mocket/decorators/mocketizer.py b/mocket/decorators/mocketizer.py index 2bf2b9cd..4020d52b 100644 --- a/mocket/decorators/mocketizer.py +++ b/mocket/decorators/mocketizer.py @@ -92,4 +92,4 @@ def wrapper( return test(*args, **kwargs) -mocketize = get_mocketize(wrapper_=wrapper) +mocketize = get_mocketize(wrapper) diff --git a/mocket/utils.py b/mocket/utils.py index ab293776..60ddd9f2 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -1,8 +1,11 @@ from __future__ import annotations import binascii +import contextlib from typing import Callable +import decorator + from mocket.compat import decode_from_bytes, encode_to_bytes @@ -28,14 +31,10 @@ def hexload(string: str) -> bytes: def get_mocketize(wrapper_: Callable) -> Callable: - import decorator - - if decorator.__version__ < "5": # type: ignore[attr-defined] # pragma: no cover - return decorator.decorator(wrapper_) - return decorator.decorator( # type: ignore[call-arg] # kwsyntax - wrapper_, - kwsyntax=True, - ) + # trying to support different versions of `decorator` + with contextlib.suppress(TypeError): + return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[call-arg,unused-ignore] + return decorator.decorator(wrapper_) __all__ = ( diff --git a/pyproject.toml b/pyproject.toml index 5418304e..fbe47465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "decorator>=4.0.0", "urllib3>=1.25.3", "h11", + "typing-extensions", ] dynamic = ["version"] @@ -124,6 +125,7 @@ files = [ ] strict = true warn_unused_configs = true +suppress_unused_ignore = true ignore_missing_imports = true warn_redundant_casts = true warn_unused_ignores = true From 09baa5ae188b046e98383983910d624fea4210e5 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 26 Mar 2025 13:17:08 +0100 Subject: [PATCH 55/98] Non-existing config. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fbe47465..41089fe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,6 @@ files = [ ] strict = true warn_unused_configs = true -suppress_unused_ignore = true ignore_missing_imports = true warn_redundant_casts = true warn_unused_ignores = true From 0db747476c3db0b8d944ad5369d444b8e8058755 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 7 Apr 2025 12:56:15 +0200 Subject: [PATCH 56/98] Switching to `codecov.io` for coverage (#285) --- .coveralls.yml | 1 - .github/workflows/main.yml | 8 ++++---- Makefile | 2 +- README.rst | 4 ++-- pyproject.toml | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) delete mode 100644 .coveralls.yml diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 22c66215..00000000 --- a/.coveralls.yml +++ /dev/null @@ -1 +0,0 @@ -repo_token: 3yI8EwDqrGZaPCnfih1fSDizXbjwwL623 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bb976ac9..c119d46f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,7 +46,7 @@ jobs: run: | make test make services-down - - name: Push Coveralls - run: | - pip install -q coveralls coveralls[yaml] - coveralls + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Makefile b/Makefile index ff6a32ec..3452a467 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ publish: clean install-test-requirements uv publish clean: - rm -rf .coverage *.egg-info dist/ requirements.txt uv.lock || true + rm -rf *.egg-info dist/ requirements.txt uv.lock || true find . -type d -name __pycache__ -exec rm -rf {} \; || true .PHONY: clean publish safetest test setup develop lint-python test-python _services-up diff --git a/README.rst b/README.rst index 8b08200f..b693dd7d 100644 --- a/README.rst +++ b/README.rst @@ -5,8 +5,8 @@ mocket /mɔˈkɛt/ .. image:: https://github.com/mindflayer/python-mocket/actions/workflows/main.yml/badge.svg?branch=main :target: https://github.com/mindflayer/python-mocket/actions?query=workflow%3A%22Mocket%27s+CI%22 -.. image:: https://coveralls.io/repos/github/mindflayer/python-mocket/badge.svg?branch=main - :target: https://coveralls.io/github/mindflayer/python-mocket?branch=main +.. image:: https://codecov.io/github/mindflayer/python-mocket/graph/badge.svg?token=htRySebRBt + :target: https://codecov.io/github/mindflayer/python-mocket .. image:: https://app.codacy.com/project/badge/Grade/6327640518ce42adaf59368217028f14 :target: https://www.codacy.com/gh/mindflayer/python-mocket/dashboard diff --git a/pyproject.toml b/pyproject.toml index 41089fe9..be889861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ exclude = [ testpaths = [ "tests", "mocket", ] -addopts = "--doctest-modules --cov=mocket --cov-report=term-missing --cov-append -v -x" +addopts = "--doctest-modules --cov=mocket --cov-report=xml --cov-report=term-missing --cov-append -v -x" [tool.ruff] src = ["mocket", "tests"] From e5dd9e1a98d941e85ec4ec347b3c91553e286f3c Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 21 Apr 2025 04:28:32 +0200 Subject: [PATCH 57/98] Update main.yml (#286) --- .github/workflows/main.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c119d46f..4baa6f34 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,6 +23,9 @@ jobs: strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.10'] + env: + # Configure a constant location for the uv cache + UV_CACHE_DIR: /tmp/.uv-cache steps: - uses: actions/checkout@v4 @@ -30,9 +33,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: | - pyproject.toml + - name: Restore uv cache + uses: actions/cache@v4 + with: + path: /tmp/.uv-cache + key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + uv-${{ runner.os }} - uses: hoverkraft-tech/compose-action@v2.0.2 with: compose-file: "./docker-compose.yml" @@ -46,6 +54,8 @@ jobs: run: | make test make services-down + - name: Minimize uv cache + run: uv cache prune --ci - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: From 3c7eec6b170365f32458ea740e8283d763b93bad Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 28 Apr 2025 18:27:20 +0200 Subject: [PATCH 58/98] Better conf for `pytest-asyncio`. (#288) --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index be889861..6872741f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,8 @@ testpaths = [ "tests", "mocket", ] addopts = "--doctest-modules --cov=mocket --cov-report=xml --cov-report=term-missing --cov-append -v -x" +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" [tool.ruff] src = ["mocket", "tests"] From d51a3bb06a92a29848ae52bf43e65358f7338b65 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sat, 3 May 2025 18:15:48 -0400 Subject: [PATCH 59/98] Fix test_truesendall failure on MacOS (#289) Closes: #287 Signed-off-by: Ihar Hrachyshka --- mocket/socket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/socket.py b/mocket/socket.py index 3b1862e2..f8f77dac 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -158,7 +158,7 @@ def getblocking(self) -> bool: return self.gettimeout() is None def getsockname(self) -> _RetAddress: - return true_gethostbyname(self._address[0]), self._address[1] + return socket.gethostbyname(self._address[0]), self._address[1] def connect(self, address: Address) -> None: self._address = self._host, self._port = address From 2ad3becdc8d0a265d0987af4508d9babe51a2443 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 4 May 2025 00:17:18 +0200 Subject: [PATCH 60/98] Bump version. --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index fc941ca5..cd9437e9 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.4" +__version__ = "3.13.5" From a27d9d9397ef1ea5e6b0892f78ce889a80cde685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Sousa?= Date: Tue, 6 May 2025 22:13:16 +0100 Subject: [PATCH 61/98] Add type hints to mocket.plugins.httpretty (#290) * Add type hints to mocket.plugins.httpretty * Add types-requests test dependency * Add unit test to get_mocketize --- mocket/mocket.py | 6 ++-- mocket/mocks/mockhttp.py | 2 +- mocket/plugins/httpretty/__init__.py | 48 +++++++++++++++------------- mocket/utils.py | 30 ++++++++++++++--- pyproject.toml | 12 +++++++ tests/test_mocket_utils.py | 31 ++++++++++++++++++ 6 files changed, 99 insertions(+), 30 deletions(-) create mode 100644 tests/test_mocket_utils.py diff --git a/mocket/mocket.py b/mocket/mocket.py index a01a7b46..c9e6e204 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -4,7 +4,7 @@ import itertools import os from pathlib import Path -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar import mocket.inject from mocket.recording import MocketRecordStorage @@ -99,12 +99,12 @@ def reset(cls) -> None: cls._record_storage = None @classmethod - def last_request(cls): + def last_request(cls) -> Any: if cls.has_requests(): return cls._requests[-1] @classmethod - def request_list(cls): + def request_list(cls) -> list[Any]: return cls._requests @classmethod diff --git a/mocket/mocks/mockhttp.py b/mocket/mocks/mockhttp.py index 245a11af..3db6a65d 100644 --- a/mocket/mocks/mockhttp.py +++ b/mocket/mocks/mockhttp.py @@ -88,7 +88,7 @@ def __init__(self, body="", status=200, headers=None): self.data = self.get_protocol_data() + self.body - def get_protocol_data(self, str_format_fun_name="capitalize"): + def get_protocol_data(self, str_format_fun_name: str = "capitalize") -> bytes: status_line = f"HTTP/1.1 {self.status} {STATUS[self.status]}" header_lines = CRLF.join( ( diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index fac61840..97a2c3a4 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -1,3 +1,5 @@ +from typing import Any, Dict, Optional + from mocket import mocketize from mocket.async_mocket import async_mocketize from mocket.compat import ENCODING @@ -7,33 +9,35 @@ from mocket.mockhttp import Response as MocketHttpResponse -def httprettifier_headers(headers): +def httprettifier_headers(headers: Dict[str, str]) -> Dict[str, str]: return {k.lower().replace("_", "-"): v for k, v in headers.items()} class Request(MocketHttpRequest): @property - def body(self): - return super().body.encode(ENCODING) + def body(self) -> bytes: + return super().body.encode(ENCODING) # type: ignore[no-any-return] @property - def headers(self): + def headers(self) -> Dict[str, str]: return httprettifier_headers(super().headers) class Response(MocketHttpResponse): - def get_protocol_data(self, str_format_fun_name="lower"): + headers: Dict[str, str] + + def get_protocol_data(self, str_format_fun_name: str = "lower") -> bytes: if "server" in self.headers and self.headers["server"] == "Python/Mocket": self.headers["server"] = "Python/HTTPretty" - return super().get_protocol_data(str_format_fun_name=str_format_fun_name) + return super().get_protocol_data(str_format_fun_name=str_format_fun_name) # type: ignore[no-any-return] - def set_base_headers(self): + def set_base_headers(self) -> None: super().set_base_headers() self.headers = httprettifier_headers(self.headers) original_set_base_headers = set_base_headers - def set_extra_headers(self, headers): + def set_extra_headers(self, headers: Dict[str, str]) -> None: self.headers.update(headers) @@ -60,17 +64,17 @@ class Entry(MocketHttpEntry): def register_uri( - method, - uri, - body="HTTPretty :)", - adding_headers=None, - forcing_headers=None, - status=200, - responses=None, - match_querystring=False, - priority=0, - **headers, -): + method: str, + uri: str, + body: str = "HTTPretty :)", + adding_headers: Optional[Dict[str, str]] = None, + forcing_headers: Optional[Dict[str, str]] = None, + status: int = 200, + responses: Any = None, + match_querystring: bool = False, + priority: int = 0, + **headers: str, +) -> None: headers = httprettifier_headers(headers) if adding_headers is not None: @@ -81,9 +85,9 @@ def register_uri( def force_headers(self): self.headers = httprettifier_headers(forcing_headers) - Response.set_base_headers = force_headers + Response.set_base_headers = force_headers # type: ignore[method-assign] else: - Response.set_base_headers = Response.original_set_base_headers + Response.set_base_headers = Response.original_set_base_headers # type: ignore[method-assign] if responses: Entry.register(method, uri, *responses) @@ -110,7 +114,7 @@ def __getattr__(self, name): HTTPretty = MocketHTTPretty() -HTTPretty.register_uri = register_uri +HTTPretty.register_uri = register_uri # type: ignore[attr-defined] httpretty = HTTPretty __all__ = ( diff --git a/mocket/utils.py b/mocket/utils.py index 60ddd9f2..6180ae3f 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -2,12 +2,34 @@ import binascii import contextlib -from typing import Callable +from typing import Any, Callable, Protocol, TypeVar, overload import decorator +from typing_extensions import ParamSpec from mocket.compat import decode_from_bytes, encode_to_bytes +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +class MocketizeDecorator(Protocol): + """ + This is a generic decorator signature, currently applicable to get_mocketize. + + Decorators can be used as: + 1. A function that transforms func (the parameter) into func1 (the returned object). + 2. A function that takes keyword arguments and returns 1. + """ + + @overload + def __call__(self, func: Callable[_P, _R], /) -> Callable[_P, _R]: ... + + @overload + def __call__( + self, **kwargs: Any + ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... + def hexdump(binary_string: bytes) -> str: r""" @@ -30,11 +52,11 @@ def hexload(string: str) -> bytes: raise ValueError from e -def get_mocketize(wrapper_: Callable) -> Callable: +def get_mocketize(wrapper_: Callable) -> MocketizeDecorator: # trying to support different versions of `decorator` with contextlib.suppress(TypeError): - return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[call-arg,unused-ignore] - return decorator.decorator(wrapper_) + return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[return-value, call-arg, unused-ignore] + return decorator.decorator(wrapper_) # type: ignore[return-value] __all__ = ( diff --git a/pyproject.toml b/pyproject.toml index 6872741f..b8631517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ test = [ "wait-for-it", "mypy", "types-decorator", + "types-requests", ] speedups = [ "xxhash;platform_python_implementation=='CPython'", @@ -123,6 +124,9 @@ files = [ "mocket/exceptions.py", "mocket/compat.py", "mocket/utils.py", + "mocket/plugins/httpretty/__init__.py", + "tests/test_httpretty.py", + "tests/test_mocket_utils.py", # "tests/" ] strict = true @@ -140,3 +144,11 @@ disable_error_code = ["no-untyped-def"] # enable this once full type-coverage is [[tool.mypy.overrides]] module = "tests.*" disable_error_code = ['type-arg', 'no-untyped-def'] + +[[tool.mypy.overrides]] +module = "mocket.plugins.*" +disallow_subclassing_any = false # mypy doesn't support dynamic imports + +[[tool.mypy.overrides]] +module = "tests.test_httpretty" +disallow_untyped_decorators = true diff --git a/tests/test_mocket_utils.py b/tests/test_mocket_utils.py new file mode 100644 index 00000000..d3b5eba7 --- /dev/null +++ b/tests/test_mocket_utils.py @@ -0,0 +1,31 @@ +from typing import Callable +from unittest import TestCase +from unittest.mock import NonCallableMock, patch + +import decorator + +from mocket.utils import get_mocketize + + +def mock_decorator(func: Callable[[], None]) -> None: + return func() + + +class GetMocketizeTestCase(TestCase): + @patch.object(decorator, "decorator") + def test_get_mocketize_with_kwsyntax(self, dec: NonCallableMock) -> None: + get_mocketize(mock_decorator) + dec.assert_called_once_with(mock_decorator, kwsyntax=True) + + @patch.object(decorator, "decorator") + def test_get_mocketize_without_kwsyntax(self, dec: NonCallableMock) -> None: + dec.side_effect = [ + TypeError("kwsyntax is not supported in this version of decorator"), + mock_decorator, + ] + + get_mocketize(mock_decorator) + # First time called with kwsyntax=True, which failed with TypeError + dec.call_args_list[0].assert_compare_to((mock_decorator,), {"kwsyntax": True}) + # Second time without kwsyntax, which succeeds + dec.call_args_list[1].assert_compare_to((mock_decorator,)) From 663d053073ae426ece20d5397decaea0b067dd65 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 6 May 2025 23:47:43 +0200 Subject: [PATCH 62/98] Renaming test file. --- pyproject.toml | 2 +- tests/{test_mocket_utils.py => test_utils.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename tests/{test_mocket_utils.py => test_utils.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index b8631517..09c50435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ files = [ "mocket/utils.py", "mocket/plugins/httpretty/__init__.py", "tests/test_httpretty.py", - "tests/test_mocket_utils.py", + "tests/test_utils.py", # "tests/" ] strict = true diff --git a/tests/test_mocket_utils.py b/tests/test_utils.py similarity index 100% rename from tests/test_mocket_utils.py rename to tests/test_utils.py From 9c7ea5ecdcb5269092078bdc29a9d1bc1e86561d Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 18 May 2025 10:15:40 +0200 Subject: [PATCH 63/98] Update pyproject.toml --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 09c50435..5349ad6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ description = "Socket Mock Framework - for all kinds of socket animals, web-clie readme = { file = "README.rst", content-type = "text/x-rst" } license = { file = "LICENSE" } authors = [{ name = "Giorgio Salluzzo", email = "giorgio.salluzzo@gmail.com" }] -urls = { github = "https://github.com/mindflayer/python-mocket" } classifiers = [ "Development Status :: 6 - Mature", "Intended Audience :: Developers", @@ -35,6 +34,10 @@ dependencies = [ ] dynamic = ["version"] +[project.urls] +Homepage = "https://github.com/mindflayer/python-mocket" +Repository = "https://github.com/mindflayer/python-mocket" + [project.optional-dependencies] test = [ "pre-commit", From 80e10479d0b382f60814beb5216915e3c7c0fc42 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 18 May 2025 10:16:18 +0200 Subject: [PATCH 64/98] Bump version --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index cd9437e9..e4bc0084 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.5" +__version__ = "3.13.6" From 2230f2cc9bc3b67cdf485af655800191ceccd9ef Mon Sep 17 00:00:00 2001 From: Daniel Avdar <66269169+DanielAvdar@users.noreply.github.com> Date: Wed, 21 May 2025 08:15:45 +0300 Subject: [PATCH 65/98] Update README.rst (#291) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b693dd7d..1bab0fde 100644 --- a/README.rst +++ b/README.rst @@ -101,7 +101,7 @@ As second step, we create an `example.py` file as the following one: import json from mocket import mocketize - from mocket.mockhttp import Entry + from mocket.mocks.mockhttp import Entry import requests import pytest @@ -294,7 +294,7 @@ Example: import pytest from mocket import async_mocketize - from mocket.mockhttp import Entry + from mocket.mocks.mockhttp import Entry from mocket.plugins.aiohttp_connector import MocketTCPConnector From 6fb97207bafa523da14fcbae60478ffe6ae90d79 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 24 May 2025 14:26:12 +0200 Subject: [PATCH 66/98] Add Mocket's logo (#292) * Adding PNG logo. --- README.rst | 6 ++++++ mocket.png | Bin 0 -> 12322 bytes 2 files changed, 6 insertions(+) create mode 100644 mocket.png diff --git a/README.rst b/README.rst index 1bab0fde..e19a3d07 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,12 @@ mocket /mɔˈkɛt/ .. image:: https://img.shields.io/pypi/dm/mocket :target: https://pypistats.org/packages/mocket +.. image:: mocket.png + :height: 256px + :width: 256px + :alt: Mocket logo + :align: center + A socket mock framework ------------------------- diff --git a/mocket.png b/mocket.png new file mode 100644 index 0000000000000000000000000000000000000000..08498b4c2698f19654f36c918d32824afac6237f GIT binary patch literal 12322 zcmdsd`9GB3`~SJ`S(vdeS)yhlOR}rTHd894LJKL|^wRoLQ7S6jx9ZhKQ3++4X{D7m zq?oCMQYeZNCc7j%GmQIuZ||SJf5T@UkD2?NGiT1ZuIoJKoa;QV>#Vn@yApFI69AyJ zYUQ%^08q&y706S_$I7>jkEd%pm#kd^z~e*(*;X1^M1ibo=~IRkW05oef}@J@Qro3yIA6~b`69*u>hQL zWq>!;sHpFk2_haFi2&h;B#S|z^OQdLnHj4L{(5rcKx8(bg1@ZwLPTGCvVr*bbhjh2 zd7yCdlqLwsjAepHo*Ww3l+8!+`~P1zI}jc*L3VQ@G{Mh|SUOP8#;WXlma=fw zmO^Z5ao(}%kZqFr6f2Tfq1?Nh7e4WJETlB3cP~3+V&`IRB4u$cERVSX#MegP?&>!x zZuM>x5wLyV1N0!Llq2o$J7p%4$49c^-KI&Wb2^~G?tgj^*xv&KzjT$82&H`wSiV#p z^(11}-)ub}s*Y9?VPScbBl(vOs?I?y@rE@B51fDzroZI&p5#tcD=1L76JfE1uK#;H znttTQ-lzYsmNi{d@r3=E+rUbAw~MH#Kd0-W3O}iT&M!~|zc>e)gLOe+AEdB;ckqh*SqNBH|V_5ewkw~|8vUxcCl*5DSr=l;+1qce5;X-`Um&Gc70 zvxT#NI_7Tq?0sUy4zQ;b!E~$1En#zQK-iYA6(=skP77Zh@CEyxw$C}}A+3k=s)IQ# zs9q(NoaHFnepcbBx3r#IZTpa|z(E?^c&R_V@yyA~@YC8yXY)cQl(y1EeS`Mr7$hKC zxH~|=Y|QN{5`Jhxsk7O&W@!Zrj#>PBm@c~56V{ZUZvymvCn4OMAN%gMQWbEB{0#@< zAMM#s7hNBOZylzdTZV_EM;<j_9=7o5WsE4^XQ;}q_ zG8r!M+zRO2-!hfMt9y4aaA55O<7sb!BJMND-?ZIZWJOU0cRD$Vp_aP8J;1H^@c4n7 zhax;k2^+YU*A7?%3hxM^?di179w59!#Bi0f4vttm{C8KI_1BhcI!t|<%L%al5gZ_I zT{CEgtx944j*m7)@REU!&HwR8d5ul1zAi0oqKDh5dVa*x&__0+;S~#he!l!7x>8M% z3(~F>o>WkOSc_ji_Ar~F6y^NMziRRO4!UV#bH-B8U<{2qpR|8+%R=%Zh!7=#KLZpm zUR}K4HJJ5qXI;n%nH{bUD(zBYW8VNO?lxQvZgv8F{8ZFl8~3f`^q!wEMxoc>@tr3R zEy(zlI7gbuiclPQ8FRF8t$aIY-=2htwQ3ZZYvJF&0z{gqqZz_C`3PKA>tALKUARoO zwwwxldmf3+|9Ea+oJ<(i(iKh7Z%W)6XsAELah)=Bj<|`?<#FmhZP6`B`O8zt(nlAsXG6O$tib&;ndE)U;+)j>|G=qE&a-YFK4)sK-4Mr;=rjD&arlCM z|I0PkbsKE;bGKqpX1 zU!leYp+yI3p01E*6P9`IOw}b)EEmuvy$IYDhbzZKy-q|u)kHsn^#8#%CJ)vbaI+~N z&aHM&+XBpk9&f{Y(5M_hcPIjW#X@S$d^|Gdp1i{bu$jbU483~`H7YRKWj$`bF92+7 z*iJvA#f@n(QVdT9mktJ2s~X^OXw(pU6VoA_J}{=I%P#THet@o+&t+Fzf;R>_u8hJp zU||qY$?EtBcFq9%+d^`XeP&3CyxK`NV;9{?xVt5WPXk$Gd zicUxVwL}(hsxpzo>BzzHdco=CSE;hGpUb!> zQui6%rME*d$zm#ziC>qpNK?_g{iy?cm96hBzgCortiR34138Gb=;UIN=xd?~7w@`Drjq<=tA;^BQH(T>MnJvMz6{ ztVMw>@pv0b$?!$@SOI#4KJ468BKaG6Z1xWPL?<}L5F8|B?`#)dumnNA`xea7hMKN71^zChdG|$`f0lo>PmBbrU_o; z4TP~1Z-D|iC|xxpdyu{|0A0ouSBCX@1e%aSr^0nKu55`?uv|b3e*-ly50z{OXc`}G zLUmN{WZ_xs(1T5*!S)(}GB+N(3GQ?V!*04KgGhNn8nyicR7DgW3qKo)CR~es!Kid( z15sNAPR$Bh#z;D>Tw{soZ3^6PzY4AH1HTA%qrPv(&dYj1`@X~@3WJn5m8Ac(Bu2mEd+;IgMy$Vtm9bd)0B*9*O zir!7Q1rb+#K+BzttnsmE#1zqL=WGGPzVYK0$PR{}a|WPLyM#XtaT7WlS4PbBE2%Z9 zu)ze4gh4q~N3u&N)y1Yrw8z>5h&A_|%$^}ISLZIyr%HyGiusWOLe}Kv;syQ#v)i$e z<@g1{{~x?*H9nRe>y-nN!8*Iv{0uoOWX(*+6CQXIt>)6ztd{0#r608~HQR;uFwygl z8*Sj3Q*bJn*9BVQ?~#9{zG(<-7=qRwQ+9Z{oLV6I$5u{a`E@ax^PYqsxjjW&&hh~V zFXY=8i7ucyIcYP{rN2;TyTY5a0auu)G!X54Tb;XC zom>0c?{1?dxmIt6;mDRZ-bmfd5d4J^cbkejcMpRN1Eu}iTuFj+6`_+3COXrf*uRxx-~_${;^rP&+n+?>f-N7iTAI&D+s4e_w(OJg$wKe3*2XzULt=gXk{teuQ2cS8hQOVopZxOWc+eqthiz&Y4sJ@yxuI4rA z_e?%76AUFpq4J-wZL~ZO(6I-37BGm_KRQd}DO)>01?s@j{*g0AseXS-Rb~Qtl}W0ACaPc(n{lLrT>zr)PueQx4`e( zZ&#nmt43E_A z(Ux(ro^lsy!uvi$OQbe3z=I5JL(ZRZLl9TJtohqdC1L-l*!b&BeOV8 zS|Cz^caUhLdPgt>r1b(U@w}2P{z&q5BT?ycyxX5JR$xaExh0C$uK*iwq~OppopeAJS2F+1tGr3P6qD)!4Qw2 z0v}^NdBI#QZqr6m+Qaj}2Ol(NF>OG0v?X=1Z{|hF3e#lcZ3=iChxFR;BlmH`ebNm; zRV0yV07O;fOX|Ox%3~yfGHnonDJ5?J3)g_>OTZyLL=$be1xy>6yw_&Dm_YQPyx`6Z zt`UA;0XIeJNUYm3#VRe=f#lK0w>0bfpu1REvMrS}zTF6ahvrbZpmHD88!?|DGNy5N z^AUY>e7{`rdN7()2~_?{q{9hgD)!x`Aj^u}HHL)&V0#kqsB~~y1+bsKfphS+LtcQB z0xHW;iHls+fd+CwCcdm5(W4H82!thXw6~ak)D$J_inK@-`ccy(w1HZ1Tbga!s35qb zAsP=e!dIt&dRO)KUa?iZJQqsq$w6@k_~q_AYRlcNH|63~IN&MR6e+9VAP>xeZwc!e2T}*tgDW6^ zKA;yfw)dIwb`th8xN8mY7$ykCZz|yLIC8%~!u3d>QfvXmVIw%P6ByEJhL6`YUyU}5Gk_D&3KVSEmIJy_rlAojETFi2BQ_S z`p+~`yN^iy5+t|(VR>GQ%pY|K1bw&P#G-!Bn#ZG#^625;)wws8<5hRxSfWW7dA=Lr zPwoPbWd!NB?3;(TZ_T6Ttp{(rK-jR*1KbUhI&B1_Gm+$C+JFdn4GC|9cI=O$*cM5C zji!<2PsY$fYZz&TBxrJf8gP56zCD#?Yjcf&9bRLEQ&FA8a;6BH^Mb~PAqrqMXp|Sn zAY7o`&IdbYfVg-NgK1pCPzQ5pZ;8w{#U0u3E_EOqyFYmy6#9XPF3>;h1;%H9(Lw2l znc{GyE=x0Z>VyXvYViU3B~3lPK*99Cf0|0K@ISVC&v zN-#)|s(ZSfg48`jO-(xaq_fiRhtyrjiWzG-ZH}5=ZEYFfwU7poSL)o|n%rtC4zdDF z?lJ*dVI!9lVgmY}MqYdfDYUPQFHLRf>HfcSgU~FMs-}|ayov! zY!2OmB$uM5+MTcgWyb+@H9qkGMDd|+0n&aRK9X(%8EkxL4ahYC)PV(rTn8U$RU?i; zX!9oUDs|H{@T3f;$%1{rElut=#=y{yYvX^9zVuD<104yn;6R|t9ZxF`1S)z$65@(k zl;jR6KYT`bwWR+lhWgFLml%oMkdtY>YmnrSVay-N%LQ3_;?2Z~vB_n~EB_cDa)j!u zBctXM7r~p-8PBLmE5UMt!7Ep1AAEh!Q;Xbh>n{(r9QLOm?K8MN#dHZD>b zhE7a;6z%3y>UhYj{BMIKP>2`}fHpj(p7{uSb-4w=_I5Bi*c}iwlkJ1l#HjI}O2V#S zs3f-XyKw~0Kyz|w-iI}~!Lkm_4XM*dyN@!$)4hMQk>o*C6@HZpSAmyRh{puV%QeEQ zB>fri7!y}S)7GP<3_*|){zVUNS)?U$k+ygMD-XO-S5*60Z5|#|MLgFh4Nzf>`%>D~ zMV6~U(_kuTkS(b5F&1wE6+EiIu)#0{2T!g5(g7}B4*Fa9&X$={&U&D7uj4x^Z;ss9 z0jkT(vQYUmSktgBUf%)h)CG!Z$U()if>ec+c8)Qz+irse*kgbfuHv2nN9hx%Xk4Cb z!<{>3U?$DGi$tyl7c2kv4sEa@7!iihYsXh9wPy52(-|YYP-ddVjh59ecPC!bNMG4I zm?4gl8BVLGqX7eYdOpFmsNZ9G)gZK#$+m@?74ZFoUdb$J%W5!1KcmSV7dNj2A#Hr4 zrJS*zGTF|}RMPjUiwniAP8}92^2R0EE7wJm)_&w{B@V=n!D7axK&ZTN28C0G{^tVoou2Wzjqxq0}jrR!~C6$ znx3GlI%QF4;hwTo+C3=q0!}I5DOJ_**+SG$11`LH2?lB-6F~(&}NeYUu5ddKr?82VehzyGB{>kg!8voQgbQ3P$9`rIPB8DY7sFT(nB0 zk3_pB6`}&q&%S|f6EgDF6cB-5SS8X#mc7`s4@oj1B8Eo0Q!eTaW9u2-JAl{ot^By8-)IJhprkAPV2W4Hk^WS5gi@6QQBFhzczC`7(L6%d_^i?4}8Qi3)_B>ui ziPH(k?EAuWN_#idD(#%Bq}pE^$%e$K)xyBs>^!@mL_gpfmquUnf@fA&e)2) z5h`ox;y-_2>-=_14r#YSetRQ(Qs5I~BBsRrh?CO$yoNU+*e*3fcZ;tVGts; z_v?T+)W!EfGiSnXJE2{MJx80fh}@6R4f(SjXQ5FJ@!gd$05QJ+*ONR^(qirfbwM={ z(rOOX&rT>8vg;Pw%qMcI zu)FE0VEbd>+l9r*(yQONX|mzYD~A_Ov!tieco6C795`(M;a}c+Fg`9h+zuWj*vX1lLuLM@NaKpWD8q` z>lgzy;O2b1`Qo92GX8^^#XnA)LB*O^OqUy|UmE;5-PH_kprm?(!~+ZSQTf0^M{@%H z$X0)V+mD-6V5h+%Q{+PcQs+Yx8Bh0~d3YifYS({0Y%LnD+^}wtl?uJ_?jS5zuFBMU4@c=bMtG4abSi^kPMc57KBBI4U_^yCHYAOxR6v_SSe=#1rJdnW4l>1` ziSqWYC%1*w=T*P{^n{mbNwKxt>35iW+Im;MPY29V7auvmS748n6u*Aj zXxK_BZU8TTNr_PRZll(C_pYgiuLmJ^TD@hmwHl0G0c9&z%G{M!N%=p1TvZ}nkrSKL z_a(p+;}1Q1SBE!%OqSuZ_-jnqn^ zmA^=TC`?y;9rKAb;aDR0f%!1ybz{DKK#BXVd1C3zIx#aeH#(jbwj_|hoOHrznr z&ijs!k2s&jwvBRb2J!F_I?2G_?EQLlrR-(y7jMyPx@l&1oKq`5CPO(*=JiL3zH5=_ zxB-_KPOQED6(+dO!JpB05nXM={KFYl7huwbt$3LpYOKfGPlPVocLocrby3VKk&k+% z9PLjGE7k743z4iFJo$TQD!(koiJ=uCWy(aL4<9Q@SM=-piX1JPQjjtZ%aiFw&@en-VHTJSR z>{HYyZTRJh4M~{nxVny7+98Y#9xHwqIhM3m2d1pZLsfU+;j}ujjMIFzC`<;6;EN&7 z!cY~f%rj7xQs*q~-{RS_fUujDG&I86BRMeZ&~7zrYZ$EkpRHO$VyhLQpyX}W$#J^w z@?zbn72%{O*zGWqdAk1t{2MYiQWiL&5PD~Rvpl=V2|jMi@UT zPnmlb^N>6EC%;xG3EPfewEy$5x&J?VX@^es{%m;g=0-u+@_DxBkLY`^-~`4>{mwg0 z#luhZGoJsL$vID)`&(RfY=W;|P_{RR*kC63_4cx(_3OSfO-?a`J;Fnz4qt$qR`SHL z{a-%19xT|T*m!UdKA$&Oy&G4X(b4!Q<7uIGaPgirSTl8|djpbnAMfWg&8@z9hy!Oe zy{at8B+?5yNBDK*J&hBw$~p*5EZJ60WMUK8*V0!0Ql!OIRjcg#{cDMlAy2QTJ)TIc z5H{F49(fCZisy7LfTuk-f|?SbtC%WMV}?(-`|Wat`=fwK?q1iH__g$Hm0Le03Tt5s zb5|u+?WJ&I6Sd7e?*;mgC%lKzB}Xx@#uX_WkA#F?{L}WOhZVP5nhD2T(|>$THDkZ) z36>?=i27$M6}`0NQ+^agZy|i3=DXW%{Gys83Fy)fu)8=3o>v-hfyIG~UNoAR zviEX4IaBrp36g#Ro@>;UZrNChY&WE}J4~gPwGb6hi{4#41&|2T?^IF zv;*T6C+Db5g}(C%iM%<~(t`=Ni;m37=dGc#Ixqd2ERtzsuQWvoxz+ni_olxzgOA5! z&%#(VZFp+4vZJ}m;vv@9lqW2h$@`*wYw_J+a$cB?t>(Th9vtr-oD_3C> z3a=F7P(Dt5J6O)o&F);A^P`|^1bYCCv98Y+s{AF{*dANi@cE0gH1n1dnZzgLO^M}B z!#`?>-`6QU^{!VerI0V;)`)823F%gvI-b10?d@a@$VY9!_A3FT*SXQ@N; z{?_JgasT)b@A$~S11ErxSkjum@-jSPmu~>6HQy60{6E;Yzpq?Lu0kihv6aV<{ zA3o(aBgr0q?cpPiDZ>Q|L{F|~O~|aE;=qf`aw#g~nn+^*wTyC(T549-qB+s$<^R^JZxU5nIaTYGEDK>Q1LhcXsk0c`W?peaK?mzu#Yd#2T zImYsWL$y|8zpQD|-{0KIot|ikG;E>hX(;1Gqv>{XQEirh#C&4X%M~^*VW!z4;e#p~ ziUZ-j=V8e&XYuR5pDKiskW*lG7*QXIO+_zuLuP519L78OoWDufsyX;)rlg*=a{>1Znx!D>VP)%yC%^dISRyUj+|Jr5FMfZe zv*bTv7{BGtr)xB87rZrb=x+w6NM7@6g>Af48S%JEhcHDYxpu%Y#NlaT3@WU!_eC=c62{l7qgKby4dTi1J zzA*1F;OMRPipM{d_56`JO@-V=X1WgFl6TlDsM5r1Ip1cA>owV1knN|h7v%u?A69dC zI1h0yGLS4JZvV0I)s`1ja60D;)&Z4x{KwGK*X<`3wySf8Z@zlR3N?QGJmU4E!yhLL z)VXbg3uHb0R`Z4u7t*5!>N}pJi!`{$@=nWbklB=hz8PGuS{@(F#NJywW)3ATVVdR! zr3`jFW@nR8-5%=k%HQ|#(ec%?iyuHoALT&0>)`H^$A==u z9v1mZ-3=(o_SI$4MMDKo!O_{Y*=oLnCwbMFI>V|xeocl471sMIqYt(j!RTeZC>#~9aUCShxAxJSYbosebKVCLW4OV}De zg#=#_1`9Ku$osi7#l4lM-?1ij1OdkQ%u_PMTx8}9Z(^7-e)Jjm0FY=b?UKasG#Weg zuAC`Q<%S|gu;HbN#LUHQzaT4Y50EYsHE-BEmW{8g zrAXbs7>IML^JRtde!p!{)4iz9FA$=mt2j^|Pqfgy!!)_iC<(j=i>BjQFP4Jyaz=1v zF0MYG44wT_wmxk00eI1OOUrK-$NB(w#qI*s^lhxY{Hq0`w{s@efYXZIe~oZcIh}kR zmyFfD^DSq~i$yX_TO?Sn6QbotKUgkNR1;M#-w=t^^aDdJ%Bh1b1ADemqle#otfdW% z1uBX@8Xd1<3NjW5LQ;U^EZ$0k%g7IEcwN+^LDY1)?D1`M3pKnUN;wO0_TP`J*A*-Q zr)ZvJ-YKGFm}$F$H)DBQRSle z=WVz=&tHE{Z;?5voCP8<*(Kcc@82dGEkFyNN;OmmI)d3V36=ikmoRV9jydwt z7U~F&Z@wm89rSP&+n29DR z1l+^zT49&l93T$Osagg3Dl3V&#P^Pt1avh;L6zTqogDBLG{6Yt9g)FT?@|_`K&}+tMMjXM_26Rqlw^Ams7Qli zB`kP0NLmF3(~SWcP*VIpb}G@0W~nI>oUi1BUk1)}GCz-5!vnLxTuo8lZh!zMdA4~5 z6_BoIPP7?irt&ogPCCwpJTBLd;$qR-$g`u;(8GuZIdu~`cG_CWG)Q3cc^5tyJz$E%2>^ntfFNC4DT3( zk(zQ%gY%(iLI$HX{UZJ$<+C6<>fZtuH3i_ALoEY|hAi0n+-TSsbN_& zzW5;Bro2;th<)83vAhOqh-M?*CcI1FniiFl+YNs@B7lq`&Le58(*E<2@?3l5GHu{A zM>@sXl0^;(jxy5B@#<{&MGIE@$C`mms<|IUG8ENIe847O+)*5OZX)y;blm{B3)NxvI zj5P8xIH;KS1dSSzRqreq`({9hw8;FHPEoluOcf8Brha5@w2t!oZXfupiBS*WNUJcw zCwdV<3(Krw4u~PMinx0pl49wNbY%!+(eeoXmE6)i>-ocQDR87#%*DS9w@vLsx>d-` zhu`jqbK$Z6@s z@q&>$I-)6+Q=TD$os*&S*o5dV+MT9Q!3lo|g35!5xNY=ZTHO5;au>{zA2jiZivAAV z(H&f4t-ME`_Of4A3tf_akTGycZRGAKb^&^W459UZ`OQ5*E0yQ=qAz`sewtu+TyZ@b zB+q?{HqICLgCCj=Eg%!E5r}kGBQ9voHIWT7$_!XAGM;M=ECbin96XVZuzsdgR&Y(# zA;Ll+o3gpFoQOMi4kt6EN?C>E;Wlar8w&a7D} zDrVL!oh~_wis?0rrmNP|RnzH`!E|Zn^oRQN$8_ra|AiNRNX9yv^T+Qv$2f9YpH<5} Lmpxj_JO2Lw;C$5{ literal 0 HcmV?d00001 From 25f9a3bca038e3b10c4953d06d39db9ab2af8e90 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 24 May 2025 23:20:35 +0200 Subject: [PATCH 67/98] Test for UDP socket (#293) * Testing a UDP socket. * precommit hooks' update --- .pre-commit-config.yaml | 2 +- mocket/socket.py | 10 ++++++++++ tests/test_socket.py | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7a1e1f7..4edd2b69 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: exclude: helm/ args: [ --unsafe ] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.11.2" + rev: "v0.11.11" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/mocket/socket.py b/mocket/socket.py index f8f77dac..496c9124 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -170,6 +170,11 @@ def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketIO: def get_entry(self, data: bytes) -> MocketEntry | None: return Mocket.get_entry(self._host, self._port, data) + def sendto(self, data: ReadableBuffer, address: Address | None = None) -> int: + self.connect(address) + self.sendall(data) + return len(data) + def sendall(self, data, entry=None, *args, **kwargs): if entry is None: entry = self.get_entry(data) @@ -204,6 +209,11 @@ def recv_into( buffer[: len(data)] = data return len(data) + def recvfrom( + self, buffersize: int, flags: int | None = None + ) -> tuple[bytes, _RetAddress]: + return self.recv(buffersize, flags), self._address + def recv(self, buffersize: int, flags: int | None = None) -> bytes: r_fd, _ = Mocket.get_pair((self._host, self._port)) if r_fd: diff --git a/tests/test_socket.py b/tests/test_socket.py index 112a9089..4c362f56 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -2,6 +2,7 @@ import pytest +from mocket import Mocket, MocketEntry, mocketize from mocket.socket import MocketSocket @@ -11,3 +12,21 @@ def test_blocking_socket(blocking): sock.connect(("locahost", 1234)) sock.setblocking(blocking) assert sock.getblocking() is blocking + + +@mocketize +def test_udp_socket(): + host = "127.0.0.1" + port = 9999 + request_data = b"ping" + response_data = b"pong" + + Mocket.register(MocketEntry((host, port), [response_data])) + + # Your UDP client code + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(request_data, (host, port)) + data, address = sock.recvfrom(1024) + + assert data == response_data + assert address == (host, port) From 0f840e9f09ebb6f0d5d0b8f97eaacd3c82fd0781 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 25 May 2025 08:23:17 +0200 Subject: [PATCH 68/98] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5349ad6c..66b394be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ dynamic = ["version"] [project.urls] -Homepage = "https://github.com/mindflayer/python-mocket" +Homepage = "https://pypi.org/project/mocket" Repository = "https://github.com/mindflayer/python-mocket" [project.optional-dependencies] From 88f71d1d1b2d5169c33ed2e653b1efb9a8ea7d00 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 25 May 2025 08:24:16 +0200 Subject: [PATCH 69/98] Bump version --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index e4bc0084..5c3e7f00 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.6" +__version__ = "3.13.7" From 2439bc96b95845fa5663b3cd68efe9b3596c25e2 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 25 May 2025 08:28:36 +0200 Subject: [PATCH 70/98] Use URL for the new logo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e19a3d07..1c95e904 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ mocket /mɔˈkɛt/ .. image:: https://img.shields.io/pypi/dm/mocket :target: https://pypistats.org/packages/mocket -.. image:: mocket.png +.. image:: https://raw.githubusercontent.com/mindflayer/python-mocket/main/mocket.png :height: 256px :width: 256px :alt: Mocket logo From 13b2b9406a6909fe9681ddbcfaa654885462b1b3 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 2 Jun 2025 07:51:40 +0200 Subject: [PATCH 71/98] Fixture `event_loop` got removed from `pytest-asyncio`. (#294) --- tests/test_asyncio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index bef53009..a1eae240 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -12,7 +12,7 @@ from mocket.plugins.aiohttp_connector import MocketTCPConnector -def test_asyncio_record_replay(event_loop): +def test_asyncio_record_replay(): async def test_asyncio_connection(): reader, writer = await asyncio.open_connection( host="google.com", @@ -33,7 +33,7 @@ async def test_asyncio_connection(): with tempfile.TemporaryDirectory() as temp_dir: with Mocketizer(truesocket_recording_dir=temp_dir): - event_loop.run_until_complete(test_asyncio_connection()) + asyncio.run(test_asyncio_connection()) files = glob.glob(f"{temp_dir}/*.json") assert len(files) == 1 From 496212ac3643b5569d83cf4dbdb1170ef169f884 Mon Sep 17 00:00:00 2001 From: Wilhelm Klopp Date: Tue, 10 Jun 2025 13:32:18 +0200 Subject: [PATCH 72/98] Match querystring for multiple responses in httpretty plugin (#295) --- mocket/plugins/httpretty/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index 97a2c3a4..34de7932 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -90,7 +90,12 @@ def force_headers(self): Response.set_base_headers = Response.original_set_base_headers # type: ignore[method-assign] if responses: - Entry.register(method, uri, *responses) + Entry.register( + method, + uri, + *responses, + match_querystring=match_querystring, + ) else: Entry.single_register( method, From 713ccd6b4e2eb435458741cec828dbee6a080126 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 10 Jun 2025 13:33:26 +0200 Subject: [PATCH 73/98] Bump version. --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 5c3e7f00..2f4c3033 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.7" +__version__ = "3.13.8" From 6dedf581b3d4f081f9b67aad44aa3066febbffbf Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Fri, 13 Jun 2025 09:39:34 +0200 Subject: [PATCH 74/98] Better Strict Mode (#298) * Output attempted address in strict mode (#296) * Update test * Make it more HTTP-agnostic. --------- Co-authored-by: Wilhelm Klopp --- mocket/decorators/mocketizer.py | 4 ++-- mocket/mode.py | 26 +++++++++++++++++++++----- mocket/socket.py | 4 ++-- tests/test_mode.py | 6 ++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/mocket/decorators/mocketizer.py b/mocket/decorators/mocketizer.py index 4020d52b..fb7c811b 100644 --- a/mocket/decorators/mocketizer.py +++ b/mocket/decorators/mocketizer.py @@ -15,9 +15,9 @@ def __init__( self.instance = instance self.truesocket_recording_dir = truesocket_recording_dir self.namespace = namespace or str(id(self)) - MocketMode().STRICT = strict_mode + MocketMode.STRICT = strict_mode if strict_mode: - MocketMode().STRICT_ALLOWED = strict_mode_allowed or [] + MocketMode.STRICT_ALLOWED = strict_mode_allowed or [] elif strict_mode_allowed: raise ValueError( "Allowed locations are only accepted when STRICT mode is active." diff --git a/mocket/mode.py b/mocket/mode.py index e1da7955..ac2ca16a 100644 --- a/mocket/mode.py +++ b/mocket/mode.py @@ -9,7 +9,7 @@ from typing import NoReturn -class MocketMode: +class _MocketMode: __shared_state: ClassVar[dict[str, Any]] = {} STRICT: ClassVar = None STRICT_ALLOWED: ClassVar = None @@ -31,7 +31,10 @@ def is_allowed(self, location: str | tuple[str, int]) -> bool: return host_allowed or location in self.STRICT_ALLOWED @staticmethod - def raise_not_allowed() -> NoReturn: + def raise_not_allowed( + address: tuple[str, int] | None = None, + data: bytes | None = None, + ) -> NoReturn: current_entries = [ (location, "\n ".join(map(str, entries))) for location, entries in Mocket._entries.items() @@ -39,7 +42,20 @@ def raise_not_allowed() -> NoReturn: formatted_entries = "\n".join( [f" {location}:\n {entries}" for location, entries in current_entries] ) - raise StrictMocketException( - "Mocket tried to use the real `socket` module while STRICT mode was active.\n" - f"Registered entries:\n{formatted_entries}" + msg = ( + "Mocket tried to use the real `socket` module while STRICT mode was active." ) + if address: + host, port = address + msg += f"\nAttempted address: {host}:{port}" + if data: + from mocket.compat import decode_from_bytes + + preview = decode_from_bytes(data).split("\r\n", 1)[0][:200] + msg += f"\nSent data: {preview}" + + msg += f"\nRegistered entries:\n{formatted_entries}" + raise StrictMocketException(msg) + + +MocketMode = _MocketMode() diff --git a/mocket/socket.py b/mocket/socket.py index 496c9124..41b25bcc 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -228,8 +228,8 @@ def recv(self, buffersize: int, flags: int | None = None) -> bytes: raise exc def true_sendall(self, data: bytes, *args: Any, **kwargs: Any) -> bytes: - if not MocketMode().is_allowed(self._address): - MocketMode.raise_not_allowed() + if not MocketMode.is_allowed(self._address): + MocketMode.raise_not_allowed(self._address, data) # try to get the response from recordings if Mocket._record_storage: diff --git a/tests/test_mode.py b/tests/test_mode.py index ea5905b0..bfdb2a79 100644 --- a/tests/test_mode.py +++ b/tests/test_mode.py @@ -52,6 +52,8 @@ def test_strict_mode_error_message(): str(exc_info.value) == """ Mocket tried to use the real `socket` module while STRICT mode was active. +Attempted address: httpbin.local:80 +Sent data: GET /ip HTTP/1.1 Registered entries: ('httpbin.local', 80): Entry(method='GET', schema='http', location=('httpbin.local', 80), path='/user.agent', query='') @@ -67,5 +69,5 @@ def test_strict_mode_false_with_allowed_hosts(): @pytest.mark.parametrize("strict_mode_on", (False, True)) def test_strict_mode_allowed_or_not(strict_mode_on): with Mocketizer(strict_mode=strict_mode_on): - assert MocketMode().is_allowed("foobar.com") is not strict_mode_on - assert MocketMode().is_allowed(("foobar.com", 443)) is not strict_mode_on + assert MocketMode.is_allowed("foobar.com") is not strict_mode_on + assert MocketMode.is_allowed(("foobar.com", 443)) is not strict_mode_on From e5b227527c179b96e34b264ab79a69fde4b48389 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Fri, 13 Jun 2025 09:40:15 +0200 Subject: [PATCH 75/98] Bump version. --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 2f4c3033..79955f6a 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.8" +__version__ = "3.13.9" From 523ce171667b33f0becd15d7810cd43d1b7f0d67 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 14 Jul 2025 18:39:57 +0200 Subject: [PATCH 76/98] Adding `socket` methods used by `trio` (#300) * Adding `socket` methods used by `trio`, not 100% sure about the actual implementation. * Bump version. --- mocket/__init__.py | 2 +- mocket/socket.py | 82 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_socket.py | 96 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 79955f6a..eaf33dff 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.9" +__version__ = "3.13.10" diff --git a/mocket/socket.py b/mocket/socket.py index 41b25bcc..e06a1a8e 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -191,6 +191,76 @@ def sendall(self, data, entry=None, *args, **kwargs): self.io.truncate() self.io.seek(0) + def sendmsg( + self, + buffers: list[ReadableBuffer], + ancdata: list[tuple[int, bytes]] | None = None, + flags: int = 0, + address: Address | None = None, + ) -> int: + if not buffers: + return 0 + + data = b"".join(bytes(b) for b in buffers) + self.sendall(data) + return len(data) + + def recvmsg( + self, + buffersize: int | None = None, + ancbufsize: int | None = None, + flags: int = 0, + ) -> tuple[bytes, list[tuple[int, bytes]]]: + """ + Receive a message from the socket. + This is a mock implementation that reads from the MocketSocketIO. + """ + try: + data = self.recv(buffersize) + except BlockingIOError: + return b"", [] + + # Mocking the ancillary data and flags as empty + return data, [] + + def recvmsg_into( + self, + buffers: list[ReadableBuffer], + ancbufsize: int | None = None, + flags: int = 0, + address: Address | None = None, + ): + """ + Receive a message into multiple buffers. + This is a mock implementation that reads from the MocketSocketIO. + """ + if not buffers: + return 0 + + try: + data = self.recv(len(buffers[0])) + except BlockingIOError: + return 0 + + for i, buffer in enumerate(buffers): + if i < len(data): + buffer[: len(data)] = data + else: + buffer[:] = b"" + return len(data) + + def recvfrom_into( + self, + buffer: WriteableBuffer, + buffersize: int | None = None, + flags: int | None = None, + ): + """ + Receive data into a buffer and return the number of bytes received. + This is a mock implementation that reads from the MocketSocketIO. + """ + return self.recv_into(buffer, buffersize, flags), self._address + def recv_into( self, buffer: WriteableBuffer, @@ -286,6 +356,18 @@ def send( self._entry = entry return len(data) + def accept(self) -> tuple[MocketSocket, _RetAddress]: + """Accept a connection and return a new MocketSocket object.""" + new_socket = MocketSocket( + family=self._family, + type=self._type, + proto=self._proto, + ) + new_socket._address = (self._host, self._port) + new_socket._host = self._host + new_socket._port = self._port + return new_socket, (self._host, self._port) + def close(self) -> None: if self._true_socket and not self._true_socket._closed: self._true_socket.close() diff --git a/pyproject.toml b/pyproject.toml index 66b394be..a921e223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ test = [ "mypy", "types-decorator", "types-requests", + "trio", ] speedups = [ "xxhash;platform_python_implementation=='CPython'", diff --git a/tests/test_socket.py b/tests/test_socket.py index 4c362f56..dad62a33 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -30,3 +30,99 @@ def test_udp_socket(): assert data == response_data assert address == (host, port) + + +def test_recvmsg(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + test_data = b"hello world" + sock._io = type("MockIO", (), {"read": lambda self, n: test_data})() + data, ancdata = sock.recvmsg(1024) + assert data == test_data + assert ancdata == [] + + +def test_recvmsg_into(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + test_data = b"foobar" + sock._io = type("MockIO", (), {"read": lambda self, n: test_data})() + buf = bytearray(10) + buf2 = bytearray(10) + buffers = [buf, buf2] + nbytes = sock.recvmsg_into(buffers) + assert nbytes == len(test_data) + assert buf[: len(test_data)] == test_data + + +def test_recvmsg_into_empty_buffers(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + result = sock.recvmsg_into([]) + assert result == 0 + + +def test_accept(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + sock._host = "127.0.0.1" + sock._port = 8080 + new_sock, addr = sock.accept() + assert isinstance(new_sock, MocketSocket) + assert new_sock is not sock + assert addr == ("127.0.0.1", 8080) + assert new_sock._host == "127.0.0.1" + assert new_sock._port == 8080 + + +@mocketize +def test_sendmsg(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + sock._host = "127.0.0.1" + sock._port = 8080 + response_data = b"pong" + + Mocket.register(MocketEntry((sock._host, sock._port), [response_data])) + + msg = [b"foo", b"bar", b"foobaz"] + total_sent = sock.sendmsg(msg) + assert total_sent == sum(len(m) for m in msg) + assert Mocket.last_request() == b"".join(msg) + + +def test_sendmsg_empty_buffers(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + result = sock.sendmsg([]) + assert result == 0 + + +def test_recvmsg_no_data(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + # Mock _io.read to return empty bytes + sock._io = type("MockIO", (), {"read": lambda self, n: b""})() + data, ancdata = sock.recvmsg(1024) + assert data == b"" + assert ancdata == [] + + +def test_recvmsg_into_no_data(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + # Mock _io.read to return empty bytes + sock._io = type("MockIO", (), {"read": lambda self, n: b""})() + buf = bytearray(10) + nbytes = sock.recvmsg_into([buf]) + assert nbytes == 0 + assert buf == bytearray(10) + + +def test_getsockopt(): + # getsockopt is a static method, so we can call it directly + result = MocketSocket.getsockopt(0, 0) + assert result == socket.SOCK_STREAM + + +def test_recvfrom_into(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + test_data = b"abc123" + sock._io = type("MockIO", (), {"read": lambda self, n: test_data})() + buf = bytearray(10) + nbytes, addr = sock.recvfrom_into(buf) + assert nbytes == len(test_data) + assert buf[:nbytes] == test_data + assert addr == sock._address From 4aab12568aece7b827ac078ef83201355f2f6467 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 23 Jul 2025 15:46:19 +0200 Subject: [PATCH 77/98] Improving coverage. (#301) --- mocket/mocket.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mocket/mocket.py b/mocket/mocket.py index c9e6e204..2a21a0ca 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -36,9 +36,7 @@ def enable( if truesocket_recording_dir is not None: recording_dir = Path(truesocket_recording_dir) - if not recording_dir.is_dir(): - # JSON dumps will be saved here - raise AssertionError + assert recording_dir.is_dir(), f"Not a directory: {recording_dir}" cls._record_storage = MocketRecordStorage( directory=recording_dir, @@ -118,15 +116,11 @@ def has_requests(cls) -> bool: @classmethod def get_namespace(cls) -> str | None: - if not cls._record_storage: - return None - return cls._record_storage.namespace + return cls._record_storage.namespace if cls._record_storage else None @classmethod def get_truesocket_recording_dir(cls) -> str | None: - if not cls._record_storage: - return None - return str(cls._record_storage.directory) + return str(cls._record_storage.directory) if cls._record_storage else None @classmethod def assert_fail_if_entries_not_served(cls) -> None: From 4cd3ece88356ec1dc438d91f01f2fe803ff0e00a Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 24 Jul 2025 13:03:56 +0200 Subject: [PATCH 78/98] Update README.rst --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index 1c95e904..ad19ea65 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,20 @@ A socket mock framework ...and then MicroPython's *urequests* (*mocket >= 3.9.1*) +What is it about? +================= + +In a nutshell, **Mocket** is *monkey-patching on steroids* for the ``socket`` and ``ssl`` modules. + +It’s designed to serve two main purposes: + +- As a **low-level framework** — for example, if you're building a client for a new database or protocol. +- As a **ready-to-use mock** — perfect for testing HTTP or HTTPS calls from any client library. + +To demonstrate that Mocket is more than just a web client mocking tool, it even includes a simple Redis mock. + +The main goal of Mocket is to make it easier to test Python clients that communicate using the ``socket`` protocol. + Outside GitHub ============== From 707e40dff18df7f74065744a54bc2d663ea81de8 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 24 Jul 2025 13:22:24 +0200 Subject: [PATCH 79/98] Fix for GIT URLs. (#302) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ad19ea65..0e29c71b 100644 --- a/README.rst +++ b/README.rst @@ -83,8 +83,8 @@ The starting point to understand how to use *Mocket* to write a custom mock is t As next step, you are invited to have a look at the implementation of both the mocks it provides: -- HTTP mock (similar to HTTPretty) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mocks/mockhttp.py -- Redis mock (basic implementation) - https://github.com/mindflayer/python-mocket/blob/master/mocket/mocks/mockredis.py +- HTTP mock (similar to HTTPretty) - https://github.com/mindflayer/python-mocket/blob/main/mocket/mocks/mockhttp.py +- Redis mock (basic implementation) - https://github.com/mindflayer/python-mocket/blob/main/mocket/mocks/mockredis.py Please also have a look at the huge test suite: From 09c9a4ead35db2e86f914deb105190a39e3afb69 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 16 Aug 2025 08:24:27 +0200 Subject: [PATCH 80/98] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0e29c71b..04ac666c 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As next step, you are invited to have a look at the implementation of both the m Please also have a look at the huge test suite: -- Tests module at https://github.com/mindflayer/python-mocket/tree/master/tests +- Tests module at https://github.com/mindflayer/python-mocket/tree/main/tests Installation ============ From d193c96d15b64c764a693f1f739411075906124a Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sat, 23 Aug 2025 18:58:09 +0200 Subject: [PATCH 81/98] Better abstraction for when inheriting from `mockhttp.Entry` (#305) * Better abstraction for when inheriting from `mockhttp.Entry`. * Pre-commit hooks bump. * Better tests for when adding trailing slash. * Adding coverage file to `make clean`. * Noqa for retrocompatibility import line. * Pytest xfail for GitHub actions runners blocked. --- .pre-commit-config.yaml | 4 ++-- Makefile | 2 +- mocket/mocks/mockhttp.py | 25 ++++++++++++++----------- tests/test_http.py | 24 +++++++++++++++++++++++- tests/test_https.py | 5 ++++- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4edd2b69..9eb1eca3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: forbid-crlf - id: remove-crlf - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -15,7 +15,7 @@ repos: exclude: helm/ args: [ --unsafe ] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.11.11" + rev: "v0.12.10" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/Makefile b/Makefile index 3452a467..445a345b 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ publish: clean install-test-requirements uv publish clean: - rm -rf *.egg-info dist/ requirements.txt uv.lock || true + rm -rf *.egg-info dist/ requirements.txt uv.lock coverage.xml || true find . -type d -name __pycache__ -exec rm -rf {} \; || true .PHONY: clean publish safetest test setup develop lint-python test-python _services-up diff --git a/mocket/mocks/mockhttp.py b/mocket/mocks/mockhttp.py index 3db6a65d..da1163b7 100644 --- a/mocket/mocks/mockhttp.py +++ b/mocket/mocks/mockhttp.py @@ -142,7 +142,9 @@ class Entry(MocketEntry): request_cls = Request response_cls = Response - def __init__(self, uri, method, responses, match_querystring=True): + default_config = {"match_querystring": True} + + def __init__(self, uri, method, responses, match_querystring: bool = True): uri = urlsplit(uri) port = uri.port @@ -151,7 +153,7 @@ def __init__(self, uri, method, responses, match_querystring=True): super().__init__((uri.hostname, port), responses) self.schema = uri.scheme - self.path = uri.path + self.path = uri.path or "/" self.query = uri.query self.method = method.upper() self._sent_data = b"" @@ -227,16 +229,15 @@ def register(cls, method, uri, *responses, **config): if "body" in config or "status" in config: raise AttributeError("Did you mean `Entry.single_register(...)`?") - default_config = dict(match_querystring=True, add_trailing_slash=True) - default_config.update(config) - config = default_config + if config.keys() - cls.default_config.keys(): + raise KeyError( + f"Invalid config keys: {config.keys() - cls.default_config.keys()}" + ) - if config["add_trailing_slash"] and not urlsplit(uri).path: - uri += "/" + _config = cls.default_config.copy() + _config.update({k: v for k, v in config.items() if k in _config}) - Mocket.register( - cls(uri, method, responses, match_querystring=config["match_querystring"]) - ) + Mocket.register(cls(uri, method, responses, **_config)) @classmethod def single_register( @@ -246,8 +247,9 @@ def single_register( body="", status=200, headers=None, - match_querystring=True, exception=None, + match_querystring=True, + **config, ): response = ( exception @@ -260,4 +262,5 @@ def single_register( uri, response, match_querystring=match_querystring, + **config, ) diff --git a/tests/test_http.py b/tests/test_http.py index afa31185..ab4057e3 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -12,7 +12,7 @@ import requests from mocket import Mocket, Mocketizer, mocketize -from mocket.mockhttp import Entry, Response +from mocket.mocks.mockhttp import Entry, Response class HttpTestCase(TestCase): @@ -433,3 +433,25 @@ def test_suggestion_for_register_and_status(self): url, status=201, ) + + def test_invalid_config_key(self): + url = "http://foobar.com/path" + with self.assertRaises(KeyError): + Entry.register( + Entry.POST, + url, + Response(body='{"foo":"bar0"}', status=200), + invalid_key=True, + ) + + def test_add_trailing_slash(self): + url = "http://testme.org" + entry = Entry(url, "GET", [Response(body='{"foo":"bar0"}', status=200)]) + self.assertEqual(entry.path, "/") + + @mocketize + def test_mocket_with_no_path(self): + Entry.register(Entry.GET, "http://httpbin.local", Response(status=202)) + response = urlopen("http://httpbin.local/") + self.assertEqual(response.code, 202) + self.assertEqual(Mocket._entries[("httpbin.local", 80)][0].path, "/") diff --git a/tests/test_https.py b/tests/test_https.py index f8c8549e..83bd38cb 100644 --- a/tests/test_https.py +++ b/tests/test_https.py @@ -7,7 +7,7 @@ import requests from mocket import Mocket, Mocketizer, mocketize -from mocket.mockhttp import Entry +from mocket.mockhttp import Entry # noqa - test retrocompatibility @pytest.fixture @@ -43,6 +43,7 @@ def test_json(response): @pytest.mark.skipif('os.getenv("SKIP_TRUE_HTTP", False)') +@pytest.mark.xfail(reason="Service down or blocking GitHub actions IPs") def test_truesendall_with_recording_https(url_to_mock): with tempfile.TemporaryDirectory() as temp_dir, Mocketizer( truesocket_recording_dir=temp_dir @@ -62,6 +63,7 @@ def test_truesendall_with_recording_https(url_to_mock): @pytest.mark.skipif('os.getenv("SKIP_TRUE_HTTP", False)') +@pytest.mark.xfail(reason="Service down or blocking GitHub actions IPs") def test_truesendall_after_mocket_session(url_to_mock): Mocket.enable() Mocket.disable() @@ -71,6 +73,7 @@ def test_truesendall_after_mocket_session(url_to_mock): @pytest.mark.skipif('os.getenv("SKIP_TRUE_HTTP", False)') +@pytest.mark.xfail(reason="Service down or blocking GitHub actions IPs") def test_real_request_session(url_to_mock): session = requests.Session() From ab9e93dbd582c505bbb6f240907cc08d5fbcbf29 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 24 Aug 2025 10:50:48 +0200 Subject: [PATCH 82/98] Define an alternative `can_handle` logic by passing a callable. (#306) --- README.rst | 39 +++++++++++++++-- mocket/__init__.py | 2 +- mocket/mocks/mockhttp.py | 64 ++++++++++++++++++++++------ mocket/plugins/httpretty/__init__.py | 2 - tests/test_http.py | 27 ++++++++++++ tests/test_https.py | 21 +++++++++ tests/test_httpx.py | 19 +++++++++ 7 files changed, 156 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 04ac666c..bc21ad90 100644 --- a/README.rst +++ b/README.rst @@ -225,6 +225,37 @@ It's very important that we test non-happy paths. with self.assertRaises(requests.exceptions.ConnectionError): requests.get(url) +Example of how to mock a call with a custom `can_handle` function +================================================================= +.. code-block:: python + + import json + + from mocket import mocketize + from mocket.mocks.mockhttp import Entry + import requests + + @mocketize + def test_can_handle(): + Entry.single_register( + Entry.GET, + url, + body=json.dumps({"message": "Nope... not this time!"}), + headers={"content-type": "application/json"}, + can_handle_fun=lambda path, qs_dict: path == "/ip" and qs_dict, + ) + Entry.single_register( + Entry.GET, + url, + body=json.dumps({"message": "There you go!"}), + headers={"content-type": "application/json"}, + can_handle_fun=lambda path, qs_dict: path == "/ip" and not qs_dict, + ) + resp = requests.get("https://httpbin.org/ip") + assert resp.status_code == 200 + assert resp.json() == {"message": "There you go!"} + + Example of how to record real socket traffic ============================================ @@ -251,10 +282,12 @@ You probably know what *VCRpy* is capable of, that's the *mocket*'s way of achie HTTPretty compatibility layer ============================= -Mocket HTTP mock can work as *HTTPretty* replacement for many different use cases. Two main features are missing: +Mocket HTTP mock can work as *HTTPretty* replacement for many different use cases. Two main features are missing, or better said, are implemented differently: + +- URL entries containing regular expressions, *Mocket* implements `can_handle_fun` which is way simpler to use and more powerful; +- response body from functions (used mostly to fake errors, *Mocket* accepts an `exception` instead). -- URL entries containing regular expressions; -- response body from functions (used mostly to fake errors, *mocket* doesn't need to do it this way). +Both features are documented above. Two features which are against the Zen of Python, at least imho (*mindflayer*), but of course I am open to call it into question. diff --git a/mocket/__init__.py b/mocket/__init__.py index eaf33dff..8d30556c 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.10" +__version__ = "3.13.11" diff --git a/mocket/mocks/mockhttp.py b/mocket/mocks/mockhttp.py index da1163b7..50a6f952 100644 --- a/mocket/mocks/mockhttp.py +++ b/mocket/mocks/mockhttp.py @@ -2,6 +2,7 @@ import time from functools import cached_property from http.server import BaseHTTPRequestHandler +from typing import Callable, Optional from urllib.parse import parse_qs, unquote, urlsplit from h11 import SERVER, Connection, Data @@ -82,9 +83,7 @@ def __init__(self, body="", status=200, headers=None): self.status = status self.set_base_headers() - - if headers is not None: - self.set_extra_headers(headers) + self.set_extra_headers(headers) self.data = self.get_protocol_data() + self.body @@ -142,9 +141,19 @@ class Entry(MocketEntry): request_cls = Request response_cls = Response - default_config = {"match_querystring": True} + default_config = {"match_querystring": True, "can_handle_fun": None} + _can_handle_fun: Optional[Callable] = None + + def __init__( + self, + uri, + method, + responses, + match_querystring: bool = True, + can_handle_fun: Optional[Callable] = None, + ): + self._can_handle_fun = can_handle_fun if can_handle_fun else self._can_handle - def __init__(self, uri, method, responses, match_querystring: bool = True): uri = urlsplit(uri) port = uri.port @@ -177,6 +186,18 @@ def collect(self, data): return consume_response + def _can_handle(self, path: str, qs_dict: dict) -> bool: + """ + The default can_handle function, which checks if the path match, + and if match_querystring is True, also checks if the querystring matches. + """ + can_handle = path == self.path + if self._match_querystring: + can_handle = can_handle and qs_dict == parse_qs( + self.query, keep_blank_values=True + ) + return can_handle + def can_handle(self, data): r""" >>> e = Entry('http://www.github.com/?bar=foo&foobar', Entry.GET, (Response(b''),)) @@ -192,13 +213,12 @@ def can_handle(self, data): except ValueError: return self is getattr(Mocket, "_last_entry", None) - uri = urlsplit(path) - can_handle = uri.path == self.path and method == self.method - if self._match_querystring: - kw = dict(keep_blank_values=True) - can_handle = can_handle and parse_qs(uri.query, **kw) == parse_qs( - self.query, **kw - ) + _request = urlsplit(path) + + can_handle = method == self.method and self._can_handle_fun( + _request.path, parse_qs(_request.query, keep_blank_values=True) + ) + if can_handle: Mocket._last_entry = self return can_handle @@ -249,8 +269,27 @@ def single_register( headers=None, exception=None, match_querystring=True, + can_handle_fun=None, **config, ): + """ + A helper method to register a single Response for a given URI and method. + Instead of passing a list of Response objects, you can just pass the response + parameters directly. + + Args: + method (str): The HTTP method (e.g., 'GET', 'POST'). + uri (str): The URI to register the response for. + body (str, optional): The body of the response. Defaults to an empty string. + status (int, optional): The HTTP status code. Defaults to 200. + headers (dict, optional): A dictionary of headers to include in the response. Defaults to None. + exception (Exception, optional): An exception to raise instead of returning a response. Defaults to None. + match_querystring (bool, optional): Whether to match the querystring in the URI. Defaults to True. + can_handle_fun (Callable, optional): A custom function to determine if the Entry can handle a request. + Defaults to None. If None, the default matching logic is used. The function should accept two parameters: + path (str), and querystring params (dict), and return a boolean. Method is matched before the function call. + **config: Additional configuration options. + """ response = ( exception if exception @@ -262,5 +301,6 @@ def single_register( uri, response, match_querystring=match_querystring, + can_handle_fun=can_handle_fun, **config, ) diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index 34de7932..fb40c0c5 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -139,6 +139,4 @@ def __getattr__(self, name): "HEAD", "PATCH", "register_uri", - "str", - "bytes", ) diff --git a/tests/test_http.py b/tests/test_http.py index ab4057e3..3d3e5b8e 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -455,3 +455,30 @@ def test_mocket_with_no_path(self): response = urlopen("http://httpbin.local/") self.assertEqual(response.code, 202) self.assertEqual(Mocket._entries[("httpbin.local", 80)][0].path, "/") + + @mocketize + def test_can_handle(self): + Entry.single_register( + Entry.POST, + "http://testme.org/foobar", + body=json.dumps({"message": "Spooky!"}), + match_querystring=False, + ) + Entry.single_register( + Entry.GET, + "http://testme.org/", + body=json.dumps({"message": "Gotcha!"}), + can_handle_fun=lambda p, q: p.endswith("/foobar") and "a" in q, + ) + Entry.single_register( + Entry.GET, + "http://testme.org/foobar", + body=json.dumps({"message": "Missed!"}), + match_querystring=False, + ) + response = requests.get("http://testme.org/foobar?a=1") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"message": "Gotcha!"}) + response = requests.get("http://testme.org/foobar?b=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"message": "Missed!"}) diff --git a/tests/test_https.py b/tests/test_https.py index 83bd38cb..4685f4eb 100644 --- a/tests/test_https.py +++ b/tests/test_https.py @@ -91,3 +91,24 @@ def test_raise_exception_from_single_register(): Entry.single_register(Entry.GET, url, exception=OSError()) with pytest.raises(requests.exceptions.ConnectionError): requests.get(url) + + +@mocketize +def test_can_handle(): + Entry.single_register( + Entry.GET, + "https://httpbin.org", + body=json.dumps({"message": "Nope... not this time!"}), + headers={"content-type": "application/json"}, + can_handle_fun=lambda path, qs_dict: path == "/ip" and qs_dict, + ) + Entry.single_register( + Entry.GET, + "https://httpbin.org", + body=json.dumps({"message": "There you go!"}), + headers={"content-type": "application/json"}, + can_handle_fun=lambda path, qs_dict: path == "/ip" and not qs_dict, + ) + resp = requests.get("https://httpbin.org/ip") + assert resp.status_code == 200 + assert resp.json() == {"message": "There you go!"} diff --git a/tests/test_httpx.py b/tests/test_httpx.py index 889a7df8..add53de8 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -194,3 +194,22 @@ async def test_httpx_fixture(httpx_client): response = await client.get(url) assert response.json() == data + + +@pytest.mark.asyncio +async def test_httpx_fixture_with_can_handle_fun(httpx_client): + url = "https://foo.bar/barfoo" + data = {"message": "Gotcha!"} + + Entry.single_register( + Entry.GET, + "https://foo.bar", + body=json.dumps(data), + headers={"content-type": "application/json"}, + can_handle_fun=lambda p, q: p.endswith("foo"), + ) + + async with httpx_client as client: + response = await client.get(url) + + assert response.json() == data From 4d1abcc1222163c3f43a43d11214ab4cc30b36d2 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 24 Aug 2025 10:56:10 +0200 Subject: [PATCH 83/98] Update example description for mocking calls --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index bc21ad90..68561020 100644 --- a/README.rst +++ b/README.rst @@ -225,8 +225,8 @@ It's very important that we test non-happy paths. with self.assertRaises(requests.exceptions.ConnectionError): requests.get(url) -Example of how to mock a call with a custom `can_handle` function -================================================================= +Example of how to mock a call with a custom request matching logic +================================================================== .. code-block:: python import json From 753202a9b55ca815a2935d126350cc5dee7345ab Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Tue, 26 Aug 2025 08:19:44 +0200 Subject: [PATCH 84/98] Add documentation --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 68561020..a9b41032 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,9 @@ mocket /mɔˈkɛt/ .. image:: https://img.shields.io/pypi/dm/mocket :target: https://pypistats.org/packages/mocket +.. image:: https://deepwiki.com/badge.svg + :target: https://deepwiki.com/mindflayer/python-mocket + .. image:: https://raw.githubusercontent.com/mindflayer/python-mocket/main/mocket.png :height: 256px :width: 256px From 6d1601dd40a84ff82a30b9d393d05f33c03e1d98 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 28 Aug 2025 17:59:39 +0200 Subject: [PATCH 85/98] Fix comment to clarify HTTPS handling in aiohttp --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a9b41032..a86a410a 100644 --- a/README.rst +++ b/README.rst @@ -340,7 +340,7 @@ Example: .. code-block:: python # `aiohttp` creates SSLContext instances at import-time - # that's why Mocket would get stuck when dealing with HTTP + # that's why Mocket would get stuck when dealing with HTTPS # Importing the module while Mocket is in control (inside a # decorated test function or using its context manager would # be enough for making it work), the alternative is using a From f4721b054431ba09f31845b729c0bfaee1ad7302 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 29 Sep 2025 04:43:21 +0200 Subject: [PATCH 86/98] Fix typo in README regarding TCPConnector --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a86a410a..b62f02a1 100644 --- a/README.rst +++ b/README.rst @@ -344,7 +344,7 @@ Example: # Importing the module while Mocket is in control (inside a # decorated test function or using its context manager would # be enough for making it work), the alternative is using a - # custom TCPConnector which always return a FakeSSLContext + # custom TCPConnector which always returns a FakeSSLContext # from Mocket like this example is showing. import aiohttp import pytest From 4fc2dfabcf37d3bf23d2c3a5518f8b121d6d3672 Mon Sep 17 00:00:00 2001 From: git-staus <215707233+git-staus@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:25:59 +0200 Subject: [PATCH 87/98] Correct the phonetic stress notation (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "vertical stroke" symbol indicates that the following syllable receives the primary stress so the "syllable separator" (the dot) then indicates unstressed syllables. I believe the stressed syllable is the first one, so change the notation to reflect this. Perhaps the second syllable should also be spelled /ɪ/ instead to match [](https://en.wiktionary.org/wiki/socket#Pronunciation) but maybe i am taking the joke too far. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b62f02a1..a79cfa05 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ =============== -mocket /mɔˈkɛt/ +mocket /ˈmɔ.kɛt/ =============== .. image:: https://github.com/mindflayer/python-mocket/actions/workflows/main.yml/badge.svg?branch=main From 44f42ca25cc082ce5bc72e039eddc856025d6cda Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 9 Oct 2025 20:29:14 +0200 Subject: [PATCH 88/98] Add support for Python 3.14 (#307) * Add Python version 3.14 * Update Python versions in GitHub Actions workflow * Bump version. * Pre-commit hooks bump. --- .github/workflows/main.yml | 2 +- .pre-commit-config.yaml | 2 +- mocket/__init__.py | 2 +- mocket/mocket.py | 2 +- pyproject.toml | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4baa6f34..0e1fd5ea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy3.10'] env: # Configure a constant location for the uv cache UV_CACHE_DIR: /tmp/.uv-cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9eb1eca3..239f9a01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: exclude: helm/ args: [ --unsafe ] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.12.10" + rev: "v0.14.0" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/mocket/__init__.py b/mocket/__init__.py index 8d30556c..857ed2ed 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -31,4 +31,4 @@ "FakeSSLContext", ) -__version__ = "3.13.11" +__version__ = "3.14.0" diff --git a/mocket/mocket.py b/mocket/mocket.py index 2a21a0ca..a8dc7997 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -19,7 +19,7 @@ class Mocket: _socket_pairs: ClassVar[dict[Address, tuple[int, int]]] = {} - _address: ClassVar[Address] = (None, None) + _address: ClassVar[Address | tuple[None, None]] = (None, None) _entries: ClassVar[dict[Address, list[MocketEntry]]] = collections.defaultdict(list) _requests: ClassVar[list] = [] _record_storage: ClassVar[MocketRecordStorage | None] = None diff --git a/pyproject.toml b/pyproject.toml index a921e223..66b429dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development", From 9f4a362c716469ac7032506031a8be8187e3cc2e Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 9 Oct 2025 23:53:13 +0200 Subject: [PATCH 89/98] Add pre-commit for validating `rst` files. (#312) --- .pre-commit-config.yaml | 4 ++++ README.rst | 4 ++-- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 239f9a01..b2cd5de5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,7 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format + - repo: https://github.com/rstcheck/rstcheck + rev: v6.2.5 + hooks: + - id: rstcheck diff --git a/README.rst b/README.rst index a79cfa05..a377f3ef 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ -=============== +================ mocket /ˈmɔ.kɛt/ -=============== +================ .. image:: https://github.com/mindflayer/python-mocket/actions/workflows/main.yml/badge.svg?branch=main :target: https://github.com/mindflayer/python-mocket/actions?query=workflow%3A%22Mocket%27s+CI%22 diff --git a/pyproject.toml b/pyproject.toml index 66b429dc..6849228d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" requires-python = ">=3.8" name = "mocket" description = "Socket Mock Framework - for all kinds of socket animals, web-clients included - with gevent/asyncio/SSL support" -readme = { file = "README.rst", content-type = "text/x-rst" } +readme = "README.rst" license = { file = "LICENSE" } authors = [{ name = "Giorgio Salluzzo", email = "giorgio.salluzzo@gmail.com" }] classifiers = [ From e1b861cf3071ba0d166a5dab4a540efaf5bd5002 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 27 Oct 2025 12:38:16 +0100 Subject: [PATCH 90/98] Update PyPy version in GitHub Actions workflow (#314) * Update PyPy version in GitHub Actions workflow * Bump Python version for MyPy. --- .github/workflows/main.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e1fd5ea..979b3518 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy3.11'] env: # Configure a constant location for the uv cache UV_CACHE_DIR: /tmp/.uv-cache diff --git a/pyproject.toml b/pyproject.toml index 6849228d..8e979b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,7 +124,7 @@ select = [ max-complexity = 8 [tool.mypy] -python_version = "3.8" +python_version = "3.13" files = [ "mocket/exceptions.py", "mocket/compat.py", From 1afed2ed20915e0d54a41b512be521f67b353e38 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 29 Oct 2025 12:10:50 +0100 Subject: [PATCH 91/98] Refactor 'hosts' script. (#315) --- scripts/patch_hosts.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/patch_hosts.sh b/scripts/patch_hosts.sh index af7e453d..ec527d2d 100644 --- a/scripts/patch_hosts.sh +++ b/scripts/patch_hosts.sh @@ -1,5 +1,9 @@ -sudo grep -v httpbin.local /etc/hosts | sudo tee /etc/hosts.mocket -export CONTAINER_ID=$(docker compose ps -q proxy) -export CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $CONTAINER_ID) -echo "$CONTAINER_IP httpbin.local" | sudo tee -a /etc/hosts.mocket -sudo mv /etc/hosts.mocket /etc/hosts +HOSTS=/etc/hosts +MOCKET_HOSTS=/etc/hosts.mocket +HTTPBIN_HOST=httpbin.local + +sudo grep -v ${HTTPBIN_HOST} ${HOSTS} | sudo tee ${MOCKET_HOSTS} +CONTAINER_ID=$(docker compose ps -q proxy) +CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ${CONTAINER_ID}) +echo "${CONTAINER_IP} ${HTTPBIN_HOST}" | sudo tee -a ${MOCKET_HOSTS} +sudo mv ${MOCKET_HOSTS} ${HOSTS} From 4edfeb8dbccab95b1a9e88c4ce8412366e32083c Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 1 Dec 2025 09:52:23 +0100 Subject: [PATCH 92/98] Fix license. --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e979b9b..0b5e9da4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,10 @@ requires-python = ">=3.8" name = "mocket" description = "Socket Mock Framework - for all kinds of socket animals, web-clients included - with gevent/asyncio/SSL support" readme = "README.rst" -license = { file = "LICENSE" } +license = "BSD-3-Clause" +license-files = [ + "LICENSE", +] authors = [{ name = "Giorgio Salluzzo", email = "giorgio.salluzzo@gmail.com" }] classifiers = [ "Development Status :: 6 - Mature", From a4a286d3ff55df05ea33c4a81ee6fa2dbf90ee3e Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 1 Dec 2025 10:24:19 +0100 Subject: [PATCH 93/98] Fix PyPy CI (#316) * Skip mypy for PyPy. --- .github/workflows/main.yml | 7 +++++-- Makefile | 8 ++++++-- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 979b3518..b5b8cff8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,8 +52,11 @@ jobs: make services-up - name: Test run: | - make test - make services-down + if [[ "${{ matrix.python-version }}" == pypy* ]]; then + SKIP_MYPY=1 make test + else + make test + fi - name: Minimize uv cache run: uv cache prune --ci - name: Upload coverage reports to Codecov diff --git a/Makefile b/Makefile index 445a345b..e35b0a57 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,12 @@ setup: develop develop: install-dev-requirements install-test-requirements types: - @echo "Type checking Python files" - $(VENV_PATH)/mypy --pretty + @if [ -n "$$SKIP_MYPY" ]; then \ + echo "Skipping mypy types check because SKIP_MYPY is set"; \ + else \ + echo "Type checking Python files"; \ + $(VENV_PATH)/mypy --pretty; \ + fi @echo "" test: types diff --git a/pyproject.toml b/pyproject.toml index 0b5e9da4..f20dbb93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ test = [ "fastapi", "aiohttp", "wait-for-it", - "mypy", + "mypy; platform_python_implementation!='PyPy'", "types-decorator", "types-requests", "trio", From 28405ff1584a27773099fbabed09dbc57056b261 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Thu, 15 Jan 2026 20:41:08 +0100 Subject: [PATCH 94/98] Add AUR Arch Linux package information to README --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a377f3ef..a6c662d4 100644 --- a/README.rst +++ b/README.rst @@ -47,12 +47,13 @@ The main goal of Mocket is to make it easier to test Python clients that communi Outside GitHub ============== -Mocket packages are available for `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, and of course from `PyPI`_. +Mocket packages are available for `openSUSE`_, `NixOS`_, `ALT Linux`_, `NetBSD`_, `AUR Arch Linux`_, and of course from `PyPI`_. .. _`openSUSE`: https://software.opensuse.org/search?baseproject=ALL&q=mocket .. _`NixOS`: https://search.nixos.org/packages?query=mocket .. _`ALT Linux`: https://packages.altlinux.org/en/sisyphus/srpms/python3-module-mocket/ .. _`NetBSD`: https://cdn.netbsd.org/pub/pkgsrc/current/pkgsrc/devel/py-mocket/index.html +.. _`AUR Arch Linux`: https://aur.archlinux.org/packages/python-mocket .. _`PyPI`: https://pypi.org/project/mocket/ From 3a184d55d553c837f912158fd756d34193149724 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Mon, 2 Feb 2026 22:28:26 +0100 Subject: [PATCH 95/98] Update copyright year in LICENSE file --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index db612228..45cf27c5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2025 Giorgio Salluzzo and individual contributors. All rights reserved. +Copyright (c) 2017-2026 Giorgio Salluzzo and individual contributors. All rights reserved. Copyright (c) 2013-2017 Andrea de Marco, Giorgio Salluzzo and individual contributors. All rights reserved. From 6407df7a8cfdcbe5034654e662546f8f1a3b4746 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 22 Feb 2026 22:45:41 +0100 Subject: [PATCH 96/98] Adding type hints and docstrings (#317) * Adding type hints and docstrings. * Add coverage.xml to .gitignore and remove from tracking * Fix setsockopt signature to match standard socket API * Add tests for setsockopt with and without optlen --- .gitignore | 1 + mocket/__init__.py | 2 + mocket/compat.py | 36 +++- mocket/decorators/async_mocket.py | 33 ++- mocket/decorators/mocketizer.py | 120 +++++++++-- mocket/entry.py | 60 +++++- mocket/exceptions.py | 7 + mocket/inject.py | 17 ++ mocket/io.py | 24 ++- mocket/mocket.py | 86 +++++++- mocket/mocks/mockhttp.py | 263 +++++++++++++++++------ mocket/mocks/mockredis.py | 124 +++++++++-- mocket/mode.py | 23 +- mocket/recording.py | 80 ++++++- mocket/socket.py | 337 +++++++++++++++++++++++++++--- mocket/ssl/context.py | 70 ++++++- mocket/ssl/socket.py | 69 +++++- mocket/types.py | 2 + mocket/urllib3.py | 20 ++ mocket/utils.py | 52 ++++- tests/test_pook.py | 1 - tests/test_socket.py | 21 ++ 22 files changed, 1276 insertions(+), 172 deletions(-) diff --git a/.gitignore b/.gitignore index 564b8ce6..9bacc469 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ shippable .vscode/ Pipfile.lock requirements.txt +coverage.xml diff --git a/mocket/__init__.py b/mocket/__init__.py index 857ed2ed..2103f97e 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -1,3 +1,5 @@ +"""Mocket - socket mocking library for Python.""" + import importlib import sys diff --git a/mocket/compat.py b/mocket/compat.py index 1ac2fc89..a8e726f6 100644 --- a/mocket/compat.py +++ b/mocket/compat.py @@ -11,23 +11,57 @@ def encode_to_bytes(s: str | bytes, encoding: str = ENCODING) -> bytes: + """Encode a string or bytes to bytes. + + Args: + s: String or bytes to encode + encoding: Encoding to use (default: utf-8 or MOCKET_ENCODING env var) + + Returns: + Encoded bytes + """ if isinstance(s, str): s = s.encode(encoding) return bytes(s) def decode_from_bytes(s: str | bytes, encoding: str = ENCODING) -> str: + """Decode bytes or string to string. + + Args: + s: String or bytes to decode + encoding: Encoding to use (default: utf-8 or MOCKET_ENCODING env var) + + Returns: + Decoded string + """ if isinstance(s, bytes): s = codecs.decode(s, encoding, "ignore") return str(s) def shsplit(s: str | bytes) -> list[str]: + """Split a shell command string into arguments. + + Args: + s: Shell command string or bytes + + Returns: + List of shell command arguments + """ s = decode_from_bytes(s) return shlex.split(s) -def do_the_magic(body): +def do_the_magic(body: bytes) -> str: + """Detect MIME type of binary data using puremagic. + + Args: + body: Binary data to analyze + + Returns: + MIME type string + """ try: magic = puremagic.magic_string(body) except puremagic.PureError: diff --git a/mocket/decorators/async_mocket.py b/mocket/decorators/async_mocket.py index 3839d5f1..53b966c0 100644 --- a/mocket/decorators/async_mocket.py +++ b/mocket/decorators/async_mocket.py @@ -1,15 +1,34 @@ +"""Async version of Mocket decorator.""" + +from __future__ import annotations + +from typing import Any, Callable + from mocket.decorators.mocketizer import Mocketizer from mocket.utils import get_mocketize async def wrapper( - test, - truesocket_recording_dir=None, - strict_mode=False, - strict_mode_allowed=None, - *args, - **kwargs, -): + test: Callable, + truesocket_recording_dir: str | None = None, + strict_mode: bool = False, + strict_mode_allowed: list | None = None, + *args: Any, + **kwargs: Any, +) -> Any: + """Async wrapper function for @async_mocketize decorator. + + Args: + test: Async test function to wrap + truesocket_recording_dir: Directory for recording true socket calls + strict_mode: Enable STRICT mode to forbid real socket calls + strict_mode_allowed: List of allowed hosts in STRICT mode + *args: Test arguments + **kwargs: Test keyword arguments + + Returns: + Result of the test function + """ async with Mocketizer.factory( test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args ): diff --git a/mocket/decorators/mocketizer.py b/mocket/decorators/mocketizer.py index fb7c811b..b067ffdf 100644 --- a/mocket/decorators/mocketizer.py +++ b/mocket/decorators/mocketizer.py @@ -1,17 +1,34 @@ +"""Mocketizer decorator for managing Mocket lifecycle in tests.""" + +from __future__ import annotations + +from typing import Any, Callable + from mocket.mocket import Mocket from mocket.mode import MocketMode from mocket.utils import get_mocketize class Mocketizer: + """Context manager and decorator for managing Mocket lifecycle in tests.""" + def __init__( self, - instance=None, - namespace=None, - truesocket_recording_dir=None, - strict_mode=False, - strict_mode_allowed=None, - ): + instance: Any | None = None, + namespace: str | None = None, + truesocket_recording_dir: str | None = None, + strict_mode: bool = False, + strict_mode_allowed: list | None = None, + ) -> None: + """Initialize the Mocketizer. + + Args: + instance: Test instance (optional) + namespace: Namespace for recordings + truesocket_recording_dir: Directory for recording true socket calls + strict_mode: Enable STRICT mode to forbid real socket calls + strict_mode_allowed: List of allowed hosts in STRICT mode + """ self.instance = instance self.truesocket_recording_dir = truesocket_recording_dir self.namespace = namespace or str(id(self)) @@ -23,7 +40,8 @@ def __init__( "Allowed locations are only accepted when STRICT mode is active." ) - def enter(self): + def enter(self) -> None: + """Enter the Mocketizer context (enable Mocket).""" Mocket.enable( namespace=self.namespace, truesocket_recording_dir=self.truesocket_recording_dir, @@ -31,33 +49,80 @@ def enter(self): if self.instance: self.check_and_call("mocketize_setup") - def __enter__(self): + def __enter__(self) -> Mocketizer: + """Enter context manager. + + Returns: + Self for use in `with` statements + """ self.enter() return self - def exit(self): + def exit(self) -> None: + """Exit the Mocketizer context (disable Mocket).""" if self.instance: self.check_and_call("mocketize_teardown") Mocket.disable() - def __exit__(self, type, value, tb): + def __exit__(self, type: Any, value: Any, tb: Any) -> None: + """Exit context manager. + + Args: + type: Exception type + value: Exception value + tb: Traceback + """ self.exit() - async def __aenter__(self, *args, **kwargs): + async def __aenter__(self, *args: Any, **kwargs: Any) -> Mocketizer: + """Enter async context manager. + + Returns: + Self for use in `async with` statements + """ self.enter() return self - async def __aexit__(self, *args, **kwargs): + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit async context manager. + + Args: + *args: Exception arguments + **kwargs: Exception keyword arguments + """ self.exit() - def check_and_call(self, method_name): + def check_and_call(self, method_name: str) -> None: + """Check if instance has a method and call it. + + Args: + method_name: Name of method to check and call + """ method = getattr(self.instance, method_name, None) if callable(method): method() @staticmethod - def factory(test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args): + def factory( + test: Callable, + truesocket_recording_dir: str | None, + strict_mode: bool, + strict_mode_allowed: list | None, + args: tuple, + ) -> Mocketizer: + """Create a Mocketizer instance for a test function. + + Args: + test: Test function being decorated + truesocket_recording_dir: Recording directory + strict_mode: Enable STRICT mode + strict_mode_allowed: Allowed hosts in STRICT mode + args: Positional arguments to test + + Returns: + Configured Mocketizer instance + """ instance = args[0] if args else None namespace = None if truesocket_recording_dir: @@ -79,13 +144,26 @@ def factory(test, truesocket_recording_dir, strict_mode, strict_mode_allowed, ar def wrapper( - test, - truesocket_recording_dir=None, - strict_mode=False, - strict_mode_allowed=None, - *args, - **kwargs, -): + test: Callable, + truesocket_recording_dir: str | None = None, + strict_mode: bool = False, + strict_mode_allowed: list | None = None, + *args: Any, + **kwargs: Any, +) -> Any: + """Wrapper function for @mocketize decorator. + + Args: + test: Test function to wrap + truesocket_recording_dir: Recording directory + strict_mode: Enable STRICT mode + strict_mode_allowed: Allowed hosts in STRICT mode + *args: Test arguments + **kwargs: Test keyword arguments + + Returns: + Result of the test function + """ with Mocketizer.factory( test, truesocket_recording_dir, strict_mode, strict_mode_allowed, args ): diff --git a/mocket/entry.py b/mocket/entry.py index 9dbbf442..2d618472 100644 --- a/mocket/entry.py +++ b/mocket/entry.py @@ -1,22 +1,38 @@ +"""Mocket entry base class for registering mock responses.""" + +from __future__ import annotations + import collections.abc +from typing import Any from mocket.compat import encode_to_bytes from mocket.mocket import Mocket class MocketEntry: + """Base class for Mocket entries that match requests and return responses.""" + class Response(bytes): + """Response wrapper class that extends bytes.""" + @property - def data(self): + def data(self) -> bytes: + """Get the response data.""" return self - response_index = 0 - request_cls = bytes - response_cls = Response - responses = None - _served = None + response_index: int = 0 + request_cls: type = bytes + response_cls: type = Response + responses: list | None = None + _served: bool | None = None + + def __init__(self, location: tuple, responses: Any) -> None: + """Initialize a Mocket entry. - def __init__(self, location, responses): + Args: + location: Tuple of (host, port) + responses: Single response or list of responses to cycle through + """ self._served = False self.location = location @@ -34,18 +50,40 @@ def __init__(self, location, responses): r = self.response_cls(r) self.responses.append(r) - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the entry.""" return f"{self.__class__.__name__}(location={self.location})" @staticmethod - def can_handle(data): + def can_handle(data: bytes) -> bool: + """Check if this entry can handle the given request data. + + Args: + data: Request data to check + + Returns: + True if this entry can handle the request, False otherwise + """ return True - def collect(self, data): + def collect(self, data: bytes) -> None: + """Collect the request data in the Mocket singleton. + + Args: + data: Request data to collect + """ req = self.request_cls(data) Mocket.collect(req) - def get_response(self): + def get_response(self) -> bytes: + """Get the next response to send. + + Returns: + Response bytes to send to the client + + Raises: + BaseException: If a response is an exception, it will be raised + """ response = self.responses[self.response_index] if self.response_index < len(self.responses) - 1: self.response_index += 1 diff --git a/mocket/exceptions.py b/mocket/exceptions.py index f5537568..db78dbf5 100644 --- a/mocket/exceptions.py +++ b/mocket/exceptions.py @@ -1,6 +1,13 @@ +"""Mocket exception classes.""" + + class MocketException(Exception): + """Base exception class for Mocket errors.""" + pass class StrictMocketException(MocketException): + """Exception raised when a socket operation is not allowed in STRICT mode.""" + pass diff --git a/mocket/inject.py b/mocket/inject.py index 866ee563..e788a929 100644 --- a/mocket/inject.py +++ b/mocket/inject.py @@ -1,3 +1,5 @@ +"""Socket patching and restoration for Mocket injection.""" + from __future__ import annotations import contextlib @@ -12,17 +14,31 @@ def _patch(module: ModuleType, name: str, patched_value: Any) -> None: + """Patch a module with a new value and store the original. + + Args: + module: Module to patch + name: Attribute name to patch + patched_value: New value to set + """ with contextlib.suppress(KeyError): original_value, module.__dict__[name] = module.__dict__[name], patched_value _patches_restore[(module, name)] = original_value def _restore(module: ModuleType, name: str) -> None: + """Restore a module's original attribute value. + + Args: + module: Module to restore + name: Attribute name to restore + """ if original_value := _patches_restore.pop((module, name)): module.__dict__[name] = original_value def enable() -> None: + """Enable Mocket by patching socket, ssl, and urllib3 modules.""" from mocket.socket import ( MocketSocket, mock_create_connection, @@ -71,6 +87,7 @@ def enable() -> None: def disable() -> None: + """Disable Mocket by restoring all patched modules.""" for module, name in list(_patches_restore.keys()): _restore(module, name) diff --git a/mocket/io.py b/mocket/io.py index 0334410b..e815e0ec 100644 --- a/mocket/io.py +++ b/mocket/io.py @@ -1,3 +1,7 @@ +"""Mocket socket I/O implementation.""" + +from __future__ import annotations + import io import os @@ -5,13 +9,29 @@ class MocketSocketIO(io.BytesIO): - def __init__(self, address) -> None: + """A BytesIO wrapper that integrates with Mocket's pipe-based I/O.""" + + def __init__(self, address: tuple) -> None: + """Initialize the socket I/O with a socket address. + + Args: + address: Tuple of (host, port) + """ self._address = address super().__init__() - def write(self, content): + def write(self, content: bytes) -> int: + """Write content to the buffer and the pipe if available. + + Args: + content: Bytes to write + + Returns: + Number of bytes written + """ super().write(content) _, w_fd = Mocket.get_pair(self._address) if w_fd: os.write(w_fd, content) + return len(content) diff --git a/mocket/mocket.py b/mocket/mocket.py index a8dc7997..75ae6285 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -1,3 +1,5 @@ +"""Core Mocket singleton for socket mocking management.""" + from __future__ import annotations import collections @@ -18,6 +20,8 @@ class Mocket: + """Singleton class managing all mock socket operations and entries.""" + _socket_pairs: ClassVar[dict[Address, tuple[int, int]]] = {} _address: ClassVar[Address | tuple[None, None]] = (None, None) _entries: ClassVar[dict[Address, list[MocketEntry]]] = collections.defaultdict(list) @@ -30,6 +34,12 @@ def enable( namespace: str | None = None, truesocket_recording_dir: str | None = None, ) -> None: + """Enable Mocket socket mocking. + + Args: + namespace: Namespace for recording storage (defaults to id of _entries) + truesocket_recording_dir: Directory to store recorded requests/responses + """ if namespace is None: namespace = str(id(cls._entries)) @@ -47,33 +57,61 @@ def enable( @classmethod def disable(cls) -> None: + """Disable Mocket socket mocking and clean up resources.""" cls.reset() mocket.inject.disable() @classmethod def get_pair(cls, address: Address) -> tuple[int, int] | tuple[None, None]: - """ + """Get the file descriptor pair for a socket address. + Given the id() of the caller, return a pair of file descriptors as a tuple of two integers: (, ) + + Args: + address: (host, port) tuple + + Returns: + Tuple of (read_fd, write_fd) or (None, None) if not found """ return cls._socket_pairs.get(address, (None, None)) @classmethod def set_pair(cls, address: Address, pair: tuple[int, int]) -> None: - """ - Store a pair of file descriptors under the key `id_` + """Store a file descriptor pair for a socket address. + + Store a pair of file descriptors under the key `address` as a tuple of two integers: (, ) + + Args: + address: (host, port) tuple + pair: Tuple of (read_fd, write_fd) """ cls._socket_pairs[address] = pair @classmethod def register(cls, *entries: MocketEntry) -> None: + """Register mock entries with Mocket. + + Args: + *entries: Variable number of MocketEntry instances to register + """ for entry in entries: cls._entries[entry.location].append(entry) @classmethod - def get_entry(cls, host: str, port: int, data) -> MocketEntry | None: + def get_entry(cls, host: str, port: int, data: Any) -> MocketEntry | None: + """Get a matching entry for the given request data. + + Args: + host: Hostname + port: Port number + data: Request data + + Returns: + Matching MocketEntry or None + """ host = host or cls._address[0] port = port or cls._address[1] entries = cls._entries.get((host, port), []) @@ -83,11 +121,17 @@ def get_entry(cls, host: str, port: int, data) -> MocketEntry | None: return None @classmethod - def collect(cls, data) -> None: + def collect(cls, data: Any) -> None: + """Collect a request in the list of all requests. + + Args: + data: Request data to collect + """ cls._requests.append(data) @classmethod def reset(cls) -> None: + """Reset all Mocket state and clean up file descriptors.""" for r_fd, w_fd in cls._socket_pairs.values(): os.close(r_fd) os.close(w_fd) @@ -98,32 +142,62 @@ def reset(cls) -> None: @classmethod def last_request(cls) -> Any: + """Get the last request made. + + Returns: + Last request data or None if no requests + """ if cls.has_requests(): return cls._requests[-1] @classmethod def request_list(cls) -> list[Any]: + """Get the list of all requests. + + Returns: + List of all collected requests + """ return cls._requests @classmethod def remove_last_request(cls) -> None: + """Remove the last request from the request list.""" if cls.has_requests(): del cls._requests[-1] @classmethod def has_requests(cls) -> bool: + """Check if any requests have been made. + + Returns: + True if there are requests, False otherwise + """ return bool(cls.request_list()) @classmethod def get_namespace(cls) -> str | None: + """Get the recording namespace. + + Returns: + Namespace string or None if recording is not enabled + """ return cls._record_storage.namespace if cls._record_storage else None @classmethod def get_truesocket_recording_dir(cls) -> str | None: + """Get the true socket recording directory. + + Returns: + Directory path as string or None if recording is not enabled + """ return str(cls._record_storage.directory) if cls._record_storage else None @classmethod def assert_fail_if_entries_not_served(cls) -> None: - """Mocket checks that all entries have been served at least once.""" + """Assert that all registered entries have been served at least once. + + Raises: + AssertionError: If any entries have not been served + """ if not all(entry._served for entry in itertools.chain(*cls._entries.values())): raise AssertionError("Some Mocket entries have not been served") diff --git a/mocket/mocks/mockhttp.py b/mocket/mocks/mockhttp.py index 50a6f952..5ec14a62 100644 --- a/mocket/mocks/mockhttp.py +++ b/mocket/mocks/mockhttp.py @@ -1,8 +1,12 @@ +"""HTTP mocking implementation for Mocket.""" + +from __future__ import annotations + import re import time from functools import cached_property from http.server import BaseHTTPRequestHandler -from typing import Callable, Optional +from typing import Any, Callable from urllib.parse import parse_qs, unquote, urlsplit from h11 import SERVER, Connection, Data @@ -12,42 +16,79 @@ from mocket.entry import MocketEntry from mocket.mocket import Mocket -STATUS = {k: v[0] for k, v in BaseHTTPRequestHandler.responses.items()} -CRLF = "\r\n" -ASCII = "ascii" +STATUS: dict = {k: v[0] for k, v in BaseHTTPRequestHandler.responses.items()} +CRLF: str = "\r\n" +ASCII: str = "ascii" class Request: - _parser = None - _event = None + """HTTP request parser using h11.""" + + _parser: Connection | None = None + _event: Any | None = None - def __init__(self, data): + def __init__(self, data: bytes) -> None: + """Initialize the request parser. + + Args: + data: Raw HTTP request data + """ self._parser = Connection(SERVER) self.add_data(data) - def add_data(self, data): + def add_data(self, data: bytes) -> None: + """Add more data to the request. + + Args: + data: Additional raw request data + """ self._parser.receive_data(data) @property - def event(self): + def event(self) -> Any: + """Get the parsed request event. + + Returns: + The h11 request event + """ if not self._event: self._event = self._parser.next_event() return self._event @cached_property - def method(self): + def method(self) -> str: + """Get the HTTP method. + + Returns: + HTTP method (GET, POST, etc.) + """ return self.event.method.decode(ASCII) @cached_property - def path(self): + def path(self) -> str: + """Get the request path. + + Returns: + Request path with query string + """ return self.event.target.decode(ASCII) @cached_property - def headers(self): + def headers(self) -> dict: + """Get the request headers. + + Returns: + Dictionary of header names to values + """ return {k.decode(ASCII): v.decode(ASCII) for k, v in self.event.headers} @cached_property - def querystring(self): + def querystring(self) -> dict: + """Get the parsed query string. + + Returns: + Dictionary of query parameter names to lists of values + """ parts = self.path.split("?", 1) return ( parse_qs(unquote(parts[1]), keep_blank_values=True) @@ -56,7 +97,12 @@ def querystring(self): ) @cached_property - def body(self): + def body(self) -> str: + """Get the request body. + + Returns: + Decoded request body string + """ while True: event = self._parser.next_event() if isinstance(event, H11Request): @@ -64,15 +110,31 @@ def body(self): elif isinstance(event, Data): return event.data.decode(ENCODING) - def __str__(self): + def __str__(self) -> str: + """Get string representation of request. + + Returns: + Formatted request string + """ return f"{self.method} - {self.path} - {self.headers}" class Response: - headers = None - is_file_object = False + """HTTP response builder.""" + + headers: dict | None = None + is_file_object: bool = False + + def __init__( + self, body: Any = "", status: int = 200, headers: dict | None = None + ) -> None: + """Initialize an HTTP response. - def __init__(self, body="", status=200, headers=None): + Args: + body: Response body (string, bytes, or file-like object) + status: HTTP status code + headers: Dictionary of response headers + """ headers = headers or {} try: # File Objects @@ -88,6 +150,14 @@ def __init__(self, body="", status=200, headers=None): self.data = self.get_protocol_data() + self.body def get_protocol_data(self, str_format_fun_name: str = "capitalize") -> bytes: + """Get the HTTP protocol headers and status line. + + Args: + str_format_fun_name: Name of string formatting method to use + + Returns: + Bytes of protocol headers (status line and headers) + """ status_line = f"HTTP/1.1 {self.status} {STATUS[self.status]}" header_lines = CRLF.join( ( @@ -97,7 +167,8 @@ def get_protocol_data(self, str_format_fun_name: str = "capitalize") -> bytes: ) return f"{status_line}\r\n{header_lines}\r\n\r\n".encode(ENCODING) - def set_base_headers(self): + def set_base_headers(self) -> None: + """Set the base response headers.""" self.headers = { "Status": str(self.status), "Date": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()), @@ -110,8 +181,12 @@ def set_base_headers(self): else: self.headers["Content-Type"] = do_the_magic(self.body) - def set_extra_headers(self, headers): - r""" + def set_extra_headers(self, headers: dict) -> None: + r"""Add extra headers to the response. + + Args: + headers: Dictionary of additional headers + >>> r = Response(body="") >>> len(r.headers.keys()) 6 @@ -126,6 +201,8 @@ def set_extra_headers(self, headers): class Entry(MocketEntry): + """HTTP entry for matching and responding to HTTP requests.""" + CONNECT = "CONNECT" DELETE = "DELETE" GET = "GET" @@ -136,22 +213,31 @@ class Entry(MocketEntry): PUT = "PUT" TRACE = "TRACE" - METHODS = (CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE) + METHODS: tuple = (CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE) - request_cls = Request - response_cls = Response + request_cls: type = Request + response_cls: type = Response - default_config = {"match_querystring": True, "can_handle_fun": None} - _can_handle_fun: Optional[Callable] = None + default_config: dict = {"match_querystring": True, "can_handle_fun": None} + _can_handle_fun: Callable | None = None def __init__( self, - uri, - method, - responses, + uri: str, + method: str, + responses: Any, match_querystring: bool = True, - can_handle_fun: Optional[Callable] = None, - ): + can_handle_fun: Callable | None = None, + ) -> None: + """Initialize an HTTP entry. + + Args: + uri: URI to match (http://host:port/path?query) + method: HTTP method (GET, POST, etc.) + responses: Response(s) to return + match_querystring: Whether to match query strings + can_handle_fun: Custom matching function + """ self._can_handle_fun = can_handle_fun if can_handle_fun else self._can_handle uri = urlsplit(uri) @@ -168,10 +254,23 @@ def __init__( self._sent_data = b"" self._match_querystring = match_querystring - def __repr__(self): + def __repr__(self) -> str: + """Get string representation of the entry. + + Returns: + String representation + """ return f"{self.__class__.__name__}(method={self.method!r}, schema={self.schema!r}, location={self.location!r}, path={self.path!r}, query={self.query!r})" - def collect(self, data): + def collect(self, data: bytes) -> bool: + """Collect the request data. + + Args: + data: Request data + + Returns: + Whether to consume the response + """ consume_response = True decoded_data = decode_from_bytes(data) @@ -187,9 +286,14 @@ def collect(self, data): return consume_response def _can_handle(self, path: str, qs_dict: dict) -> bool: - """ - The default can_handle function, which checks if the path match, - and if match_querystring is True, also checks if the querystring matches. + """Default can_handle function checking path and query string. + + Args: + path: Request path + qs_dict: Parsed query string parameters + + Returns: + True if this entry can handle the request """ can_handle = path == self.path if self._match_querystring: @@ -198,8 +302,15 @@ def _can_handle(self, path: str, qs_dict: dict) -> bool: ) return can_handle - def can_handle(self, data): - r""" + def can_handle(self, data: bytes) -> bool: + r"""Check if this entry can handle the given request data. + + Args: + data: Request data + + Returns: + True if this entry can handle the request + >>> e = Entry('http://www.github.com/?bar=foo&foobar', Entry.GET, (Response(b''),)) >>> e.can_handle(b'GET /?bar=foo HTTP/1.1\r\nHost: github.com\r\nAccept-Encoding: gzip, deflate\r\nConnection: keep-alive\r\nUser-Agent: python-requests/2.7.0 CPython/3.4.3 Linux/3.19.0-16-generic\r\nAccept: */*\r\n\r\n') False @@ -224,10 +335,20 @@ def can_handle(self, data): return can_handle @staticmethod - def _parse_requestline(line): - """ + def _parse_requestline(line: str) -> tuple: + """Parse an HTTP request line. + http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5 + Args: + line: HTTP request line string + + Returns: + Tuple of (method, path, version) + + Raises: + ValueError: If line is not a valid request line + >>> Entry._parse_requestline('GET / HTTP/1.0') == ('GET', '/', '1.0') True >>> Entry._parse_requestline('post /testurl htTP/1.1') == ('POST', '/testurl', '1.1') @@ -245,7 +366,19 @@ def _parse_requestline(line): raise ValueError("Not a Request-Line") @classmethod - def register(cls, method, uri, *responses, **config): + def register(cls, method: str, uri: str, *responses: Any, **config: Any) -> None: + """Register an HTTP entry for multiple responses. + + Args: + method: HTTP method (GET, POST, etc.) + uri: URI to match + *responses: Response(s) to cycle through + **config: Configuration options (match_querystring, can_handle_fun) + + Raises: + AttributeError: If using body/status params (use single_register instead) + KeyError: If invalid config keys provided + """ if "body" in config or "status" in config: raise AttributeError("Did you mean `Entry.single_register(...)`?") @@ -262,33 +395,31 @@ def register(cls, method, uri, *responses, **config): @classmethod def single_register( cls, - method, - uri, - body="", - status=200, - headers=None, - exception=None, - match_querystring=True, - can_handle_fun=None, - **config, - ): - """ - A helper method to register a single Response for a given URI and method. - Instead of passing a list of Response objects, you can just pass the response - parameters directly. + method: str, + uri: str, + body: Any = "", + status: int = 200, + headers: dict | None = None, + exception: Exception | None = None, + match_querystring: bool = True, + can_handle_fun: Callable | None = None, + **config: Any, + ) -> None: + """Register a single HTTP response for a URI and method. + + This is a convenience method that creates a single Response object + instead of requiring a list. Args: - method (str): The HTTP method (e.g., 'GET', 'POST'). - uri (str): The URI to register the response for. - body (str, optional): The body of the response. Defaults to an empty string. - status (int, optional): The HTTP status code. Defaults to 200. - headers (dict, optional): A dictionary of headers to include in the response. Defaults to None. - exception (Exception, optional): An exception to raise instead of returning a response. Defaults to None. - match_querystring (bool, optional): Whether to match the querystring in the URI. Defaults to True. - can_handle_fun (Callable, optional): A custom function to determine if the Entry can handle a request. - Defaults to None. If None, the default matching logic is used. The function should accept two parameters: - path (str), and querystring params (dict), and return a boolean. Method is matched before the function call. - **config: Additional configuration options. + method: HTTP method (GET, POST, etc.) + uri: URI to match + body: Response body content + status: HTTP status code + headers: Dictionary of response headers + exception: Exception to raise instead of returning response + match_querystring: Whether to match query strings + can_handle_fun: Custom matching function + **config: Additional configuration options """ response = ( exception diff --git a/mocket/mocks/mockredis.py b/mocket/mocks/mockredis.py index fc386e2d..eee2d6c8 100644 --- a/mocket/mocks/mockredis.py +++ b/mocket/mocks/mockredis.py @@ -1,4 +1,9 @@ +"""Redis mocking implementation for Mocket.""" + +from __future__ import annotations + from itertools import chain +from typing import Any from mocket.compat import ( decode_from_bytes, @@ -7,29 +12,63 @@ ) from mocket.entry import MocketEntry from mocket.mocket import Mocket +from mocket.types import Address class Request: - def __init__(self, data): + """Redis request wrapper.""" + + def __init__(self, data: bytes) -> None: + """Initialize a Redis request. + + Args: + data: Raw Redis command data + """ self.data = data class Response: - def __init__(self, data=None): + """Redis response wrapper.""" + + def __init__(self, data: Any = None) -> None: + """Initialize a Redis response. + + Args: + data: Response data (will be "redisize"d) + """ self.data = Redisizer.redisize(data or OK) class Redisizer(bytes): + """Convert Python types to Redis protocol format.""" + @staticmethod - def tokens(iterable): + def tokens(iterable: list[Any]) -> list[bytes]: + """Convert an iterable to Redis tokens. + + Args: + iterable: List of items to convert + + Returns: + List of Redis protocol bytes + """ iterable = [encode_to_bytes(x) for x in iterable] return [f"*{len(iterable)}".encode()] + list( chain(*zip([f"${len(x)}".encode() for x in iterable], iterable)) ) @staticmethod - def redisize(data): - def get_conversion(t): + def redisize(data: Any) -> Redisizer: + """Convert Python data to Redis protocol format. + + Args: + data: Python data to convert + + Returns: + Redisizer bytes + """ + + def get_conversion(t: type) -> Any: return { dict: lambda x: b"\r\n".join( Redisizer.tokens(list(chain(*tuple(x.items())))) @@ -48,11 +87,28 @@ def get_conversion(t): return Redisizer(get_conversion(data.__class__)(data) + b"\r\n") @staticmethod - def command(description, _type="+"): + def command(description: str, _type: str = "+") -> Redisizer: + """Create a Redis command response. + + Args: + description: Response description + _type: Response type prefix (+, -, :, $, *) + + Returns: + Formatted Redis response + """ return Redisizer("{}{}{}".format(_type, description, "\r\n").encode("utf-8")) @staticmethod - def error(description): + def error(description: str) -> Redisizer: + """Create a Redis error response. + + Args: + description: Error description + + Returns: + Formatted Redis error response + """ return Redisizer.command(description, _type="-") @@ -62,20 +118,46 @@ def error(description): class Entry(MocketEntry): + """Redis entry for matching and responding to Redis commands.""" + request_cls = Request response_cls = Response - def __init__(self, addr, command, responses): + def __init__( + self, addr: Address | None, command: str, responses: list[Any] + ) -> None: + """Initialize a Redis entry. + + Args: + addr: (host, port) tuple or None for default + command: Redis command string to match + responses: List of responses to cycle through + """ super().__init__(addr or ("localhost", 6379), responses) d = shsplit(command) d[0] = d[0].upper() self.command = Redisizer.tokens(d) - def can_handle(self, data): + def can_handle(self, data: bytes) -> bool: + """Check if this entry can handle the given command. + + Args: + data: Raw Redis command data + + Returns: + True if this entry matches the command + """ return data.splitlines() == self.command @classmethod - def register(cls, addr, command, *responses): + def register(cls, addr: Address | None, command: str, *responses: Any) -> None: + """Register a Redis entry. + + Args: + addr: (host, port) tuple or None for default + command: Redis command to match + *responses: Responses to cycle through + """ responses = [ r if isinstance(r, BaseException) else cls.response_cls(r) for r in responses @@ -83,9 +165,27 @@ def register(cls, addr, command, *responses): Mocket.register(cls(addr, command, responses)) @classmethod - def register_response(cls, command, response, addr=None): + def register_response( + cls, command: str, response: Any, addr: Address | None = None + ) -> None: + """Register a single response for a command. + + Args: + command: Redis command to match + response: Response to return + addr: (host, port) tuple or None for default + """ cls.register(addr, command, response) @classmethod - def register_responses(cls, command, responses, addr=None): + def register_responses( + cls, command: str, responses: list[Any], addr: Address | None = None + ) -> None: + """Register multiple responses for a command. + + Args: + command: Redis command to match + responses: List of responses to cycle through + addr: (host, port) tuple or None for default + """ cls.register(addr, command, *responses) diff --git a/mocket/mode.py b/mocket/mode.py index ac2ca16a..ffb23a44 100644 --- a/mocket/mode.py +++ b/mocket/mode.py @@ -1,3 +1,5 @@ +"""Mocket mode management for strict socket enforcement.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any, ClassVar @@ -10,17 +12,27 @@ class _MocketMode: + """Singleton class for managing Mocket's strict mode enforcement.""" + __shared_state: ClassVar[dict[str, Any]] = {} STRICT: ClassVar = None STRICT_ALLOWED: ClassVar = None def __init__(self) -> None: + """Initialize the MocketMode singleton with shared state.""" self.__dict__ = self.__shared_state def is_allowed(self, location: str | tuple[str, int]) -> bool: - """ + """Check if a location is allowed to perform real socket calls. + Checks if (`host`, `port`) or at least `host` are allowed locations to perform real `socket` calls + + Args: + location: Hostname string or (host, port) tuple + + Returns: + True if the location is allowed, False if in STRICT mode and not allowed """ if not self.STRICT: return True @@ -35,6 +47,15 @@ def raise_not_allowed( address: tuple[str, int] | None = None, data: bytes | None = None, ) -> NoReturn: + """Raise an exception when a socket operation is not allowed in STRICT mode. + + Args: + address: The (host, port) tuple that was attempted + data: The request data that was sent + + Raises: + StrictMocketException: Always raised with detailed context + """ current_entries = [ (location, "\n ".join(map(str, entries))) for location, entries in Mocket._entries.items() diff --git a/mocket/recording.py b/mocket/recording.py index 97d2adbe..95faf126 100644 --- a/mocket/recording.py +++ b/mocket/recording.py @@ -1,3 +1,5 @@ +"""Request/response recording for playback during tests.""" + from __future__ import annotations import contextlib @@ -6,12 +8,13 @@ from collections import defaultdict from dataclasses import dataclass from pathlib import Path +from typing import Any from mocket.compat import decode_from_bytes, encode_to_bytes from mocket.types import Address from mocket.utils import hexdump, hexload -hash_function = hashlib.md5 +hash_function: Any = hashlib.md5 with contextlib.suppress(ImportError): from xxhash_cffi import xxh32 as xxhash_cffi_xxh32 @@ -25,22 +28,48 @@ def _hash_prepare_request(data: bytes) -> bytes: + """Prepare request data for hashing by sorting headers. + + Args: + data: Raw request data + + Returns: + Prepared bytes for hashing + """ _data = decode_from_bytes(data) return encode_to_bytes("".join(sorted(_data.split("\r\n")))) def _hash_request(data: bytes) -> str: + """Hash a request using the best available hash function. + + Args: + data: Raw request data + + Returns: + Hex digest of the hash + """ _data = _hash_prepare_request(data) return hash_function(_data).hexdigest() def _hash_request_fallback(data: bytes) -> str: + """Hash a request using MD5 as fallback. + + Args: + data: Raw request data + + Returns: + Hex digest of the MD5 hash + """ _data = _hash_prepare_request(data) return hashlib.md5(_data).hexdigest() @dataclass class MocketRecord: + """A record of a request and its corresponding response.""" + host: str port: int request: bytes @@ -48,7 +77,15 @@ class MocketRecord: class MocketRecordStorage: + """Storage for recording and retrieving request/response pairs.""" + def __init__(self, directory: Path, namespace: str) -> None: + """Initialize the record storage. + + Args: + directory: Path to directory for storing recordings + namespace: Namespace for grouping records + """ self._directory = directory self._namespace = namespace self._records: defaultdict[Address, defaultdict[str, MocketRecord]] = ( @@ -59,17 +96,33 @@ def __init__(self, directory: Path, namespace: str) -> None: @property def directory(self) -> Path: + """Get the recording directory. + + Returns: + Path to recording directory + """ return self._directory @property def namespace(self) -> str: + """Get the recording namespace. + + Returns: + Namespace string + """ return self._namespace @property def file(self) -> Path: + """Get the path to the namespace's JSON file. + + Returns: + Path to JSON recording file + """ return self._directory / f"{self._namespace}.json" def _load(self) -> None: + """Load recordings from disk.""" if not self.file.exists(): return @@ -92,6 +145,7 @@ def _load(self) -> None: ) def _save(self) -> None: + """Save recordings to disk.""" data: dict[str, dict[str, dict[str, dict[str, str]]]] = defaultdict( lambda: defaultdict(defaultdict) ) @@ -108,9 +162,26 @@ def _save(self) -> None: self.file.write_text(json_data) def get_records(self, address: Address) -> list[MocketRecord]: + """Get all records for an address. + + Args: + address: (host, port) tuple + + Returns: + List of MocketRecord instances + """ return list(self._records[address].values()) def get_record(self, address: Address, request: bytes) -> MocketRecord | None: + """Get a specific record matching the request. + + Args: + address: (host, port) tuple + request: Request bytes + + Returns: + Matching MocketRecord or None + """ # NOTE for backward-compat request_signature_fallback = _hash_request_fallback(request) if request_signature_fallback in self._records[address]: @@ -128,6 +199,13 @@ def put_record( request: bytes, response: bytes, ) -> None: + """Store a new record. + + Args: + address: (host, port) tuple + request: Request bytes + response: Response bytes + """ host, port = address record = MocketRecord( host=host, diff --git a/mocket/socket.py b/mocket/socket.py index e06a1a8e..bd79528c 100644 --- a/mocket/socket.py +++ b/mocket/socket.py @@ -1,3 +1,5 @@ +"""Mock socket implementation for Mocket.""" + from __future__ import annotations import contextlib @@ -25,7 +27,21 @@ true_socket = socket.socket -def mock_create_connection(address, timeout=None, source_address=None): +def mock_create_connection( + address: Address, + timeout: float | None = None, + source_address: Address | None = None, +) -> socket.socket: + """Create a mock socket connection. + + Args: + address: (host, port) tuple + timeout: Connection timeout in seconds + source_address: Source address for binding (unused) + + Returns: + MocketSocket instance + """ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) if timeout: s.settimeout(timeout) @@ -41,29 +57,80 @@ def mock_getaddrinfo( proto: int = 0, flags: int = 0, ) -> list[tuple[int, int, int, str, tuple[str, int]]]: + """Mock socket.getaddrinfo function. + + Args: + host: Hostname + port: Port number + family: Address family (ignored) + type: Socket type (ignored) + proto: Protocol (ignored) + flags: Flags (ignored) + + Returns: + List of address info tuples + """ return [(2, 1, 6, "", (host, port))] def mock_gethostbyname(hostname: str) -> str: + """Mock socket.gethostbyname function. + + Args: + hostname: Hostname to resolve (unused) + + Returns: + Localhost IP address + """ return "127.0.0.1" def mock_gethostname() -> str: + """Mock socket.gethostname function. + + Returns: + Localhost hostname + """ return "localhost" def mock_inet_pton(address_family: int, ip_string: str) -> bytes: + """Mock socket.inet_pton function. + + Args: + address_family: Address family (unused) + ip_string: IP string (unused) + + Returns: + Localhost as bytes + """ return bytes("\x7f\x00\x00\x01", "utf-8") -def mock_socketpair(*args, **kwargs): - """Returns a real socketpair() used by asyncio loop for supporting calls made by fastapi and similar services.""" +def mock_socketpair( + *args: Any, + **kwargs: Any, +) -> tuple[socket.socket, socket.socket]: + """Mock socket.socketpair function. + + Returns a real socketpair() used by asyncio loop for supporting + calls made by fastapi and similar services. + + Args: + *args: Positional arguments + **kwargs: Keyword arguments + + Returns: + Tuple of two connected sockets + """ import _socket return _socket.socketpair(*args, **kwargs) class MocketSocket: + """Mock socket implementation for Mocket.""" + def __init__( self, family: socket.AddressFamily | int = socket.AF_INET, @@ -72,6 +139,15 @@ def __init__( fileno: int | None = None, **kwargs: Any, ) -> None: + """Initialize a Mocket socket. + + Args: + family: Address family + type: Socket type + proto: Protocol number + fileno: File descriptor (unused) + **kwargs: Additional keyword arguments + """ self._family = family self._type = type self._proto = proto @@ -90,9 +166,11 @@ def __init__( self._entry = None def __str__(self) -> str: + """Return a string representation of the socket.""" return f"({self.__class__.__name__})(family={self.family} type={self.type} protocol={self.proto})" def __enter__(self) -> Self: + """Enter context manager.""" return self def __exit__( @@ -101,27 +179,37 @@ def __exit__( value: BaseException | None, traceback: TracebackType | None, ) -> None: + """Exit context manager and close socket.""" self.close() @property def family(self) -> int: + """Get the address family.""" return self._family @property def type(self) -> int: + """Get the socket type.""" return self._type @property def proto(self) -> int: + """Get the protocol number.""" return self._proto @property def io(self) -> MocketSocketIO: + """Get or create the socket I/O object.""" if self._io is None: self._io = MocketSocketIO((self._host, self._port)) return self._io def fileno(self) -> int: + """Get the file descriptor for reading. + + Returns: + File descriptor number + """ address = (self._host, self._port) r_fd, _ = Mocket.get_pair(address) if not r_fd: @@ -130,52 +218,153 @@ def fileno(self) -> int: return r_fd def gettimeout(self) -> float | None: + """Get the socket timeout. + + Returns: + Timeout in seconds or None + """ return self._timeout - # FIXME the arguments here seem wrong. they should be `level: int, optname: int, value: int | ReadableBuffer | None` - def setsockopt(self, family: int, type: int, proto: int) -> None: - self._family = family - self._type = type - self._proto = proto + def setsockopt( + self, + level: int, + optname: int, + value: int | bytes | None, + optlen: int | None = None, + ) -> None: + """Set socket option. + Args: + level: Socket option level (e.g., socket.SOL_SOCKET) + optname: Socket option name (e.g., socket.SO_REUSEADDR) + value: Option value as an integer or bytes, or None when optlen is provided + optlen: Option length (used when value is None) + """ if self._true_socket: - self._true_socket.setsockopt(family, type, proto) + if optlen is not None: + self._true_socket.setsockopt(level, optname, value, optlen) + else: + self._true_socket.setsockopt(level, optname, value) def settimeout(self, timeout: float | None) -> None: + """Set the socket timeout. + + Args: + timeout: Timeout in seconds or None + """ self._timeout = timeout @staticmethod def getsockopt(level: int, optname: int, buflen: int | None = None) -> int: + """Get socket option (mock implementation). + + Args: + level: Socket option level + optname: Socket option name + buflen: Buffer length (unused) + + Returns: + SOCK_STREAM constant + """ return socket.SOCK_STREAM def getpeername(self) -> _RetAddress: + """Get the remote socket address. + + Returns: + Address of the remote socket + """ return self._address def setblocking(self, block: bool) -> None: + """Set the socket to blocking or non-blocking mode. + + Args: + block: True for blocking, False for non-blocking + """ self.settimeout(None) if block else self.settimeout(0.0) def getblocking(self) -> bool: + """Check if the socket is in blocking mode. + + Returns: + True if blocking, False otherwise + """ return self.gettimeout() is None def getsockname(self) -> _RetAddress: + """Get the local socket address. + + Returns: + Local socket address + """ return socket.gethostbyname(self._address[0]), self._address[1] def connect(self, address: Address) -> None: + """Connect the socket to a remote address. + + Args: + address: (host, port) tuple + """ self._address = self._host, self._port = address Mocket._address = address def makefile(self, mode: str = "r", bufsize: int = -1) -> MocketSocketIO: + """Create a file object for the socket. + + Args: + mode: Mode string (unused) + bufsize: Buffer size (unused) + + Returns: + MocketSocketIO object + """ return self.io def get_entry(self, data: bytes) -> MocketEntry | None: + """Get a matching entry for the given data. + + Args: + data: Request data + + Returns: + Matching MocketEntry or None + """ return Mocket.get_entry(self._host, self._port, data) - def sendto(self, data: ReadableBuffer, address: Address | None = None) -> int: + def sendto( + self, + data: ReadableBuffer, + address: Address | None = None, + ) -> int: + """Send data to a specific address (UDP-like). + + Args: + data: Data to send + address: Destination address + + Returns: + Number of bytes sent + """ self.connect(address) self.sendall(data) return len(data) - def sendall(self, data, entry=None, *args, **kwargs): + def sendall( + self, + data: ReadableBuffer, + entry: MocketEntry | None = None, + *args: Any, + **kwargs: Any, + ) -> None: + """Send all data through the socket. + + Args: + data: Data to send + entry: Pre-matched entry (optional) + *args: Additional arguments + **kwargs: Additional keyword arguments + """ if entry is None: entry = self.get_entry(data) @@ -198,6 +387,17 @@ def sendmsg( flags: int = 0, address: Address | None = None, ) -> int: + """Send a message through multiple buffers. + + Args: + buffers: List of buffers to send + ancdata: Ancillary data (unused) + flags: Flags (unused) + address: Destination address (unused) + + Returns: + Number of bytes sent + """ if not buffers: return 0 @@ -211,16 +411,23 @@ def recvmsg( ancbufsize: int | None = None, flags: int = 0, ) -> tuple[bytes, list[tuple[int, bytes]]]: - """ - Receive a message from the socket. + """Receive a message from the socket. + This is a mock implementation that reads from the MocketSocketIO. + + Args: + buffersize: Size of buffer to receive + ancbufsize: Ancillary buffer size (unused) + flags: Flags (unused) + + Returns: + Tuple of (data, ancillary_data) """ try: data = self.recv(buffersize) except BlockingIOError: return b"", [] - # Mocking the ancillary data and flags as empty return data, [] def recvmsg_into( @@ -229,10 +436,19 @@ def recvmsg_into( ancbufsize: int | None = None, flags: int = 0, address: Address | None = None, - ): - """ - Receive a message into multiple buffers. + ) -> int: + """Receive a message into multiple buffers. + This is a mock implementation that reads from the MocketSocketIO. + + Args: + buffers: List of buffers to receive into + ancbufsize: Ancillary buffer size (unused) + flags: Flags (unused) + address: Address (unused) + + Returns: + Number of bytes received """ if not buffers: return 0 @@ -254,10 +470,16 @@ def recvfrom_into( buffer: WriteableBuffer, buffersize: int | None = None, flags: int | None = None, - ): - """ - Receive data into a buffer and return the number of bytes received. - This is a mock implementation that reads from the MocketSocketIO. + ) -> tuple[int, _RetAddress]: + """Receive data into a buffer and return the source address. + + Args: + buffer: Buffer to receive into + buffersize: Size to receive + flags: Flags (unused) + + Returns: + Tuple of (bytes_received, source_address) """ return self.recv_into(buffer, buffersize, flags), self._address @@ -267,10 +489,19 @@ def recv_into( buffersize: int | None = None, flags: int | None = None, ) -> int: + """Receive data into a buffer. + + Args: + buffer: Buffer to receive into + buffersize: Number of bytes to receive + flags: Flags (unused) + + Returns: + Number of bytes received + """ if hasattr(buffer, "write"): return buffer.write(self.recv(buffersize)) - # buffer is a memoryview if buffersize is None: buffersize = len(buffer) @@ -282,9 +513,30 @@ def recv_into( def recvfrom( self, buffersize: int, flags: int | None = None ) -> tuple[bytes, _RetAddress]: + """Receive data and the source address. + + Args: + buffersize: Number of bytes to receive + flags: Flags (unused) + + Returns: + Tuple of (data, source_address) + """ return self.recv(buffersize, flags), self._address def recv(self, buffersize: int, flags: int | None = None) -> bytes: + """Receive data from the socket. + + Args: + buffersize: Maximum number of bytes to receive + flags: Flags (unused) + + Returns: + Received bytes + + Raises: + BlockingIOError: If socket is non-blocking and no data available + """ r_fd, _ = Mocket.get_pair((self._host, self._port)) if r_fd: return os.read(r_fd, buffersize) @@ -298,6 +550,19 @@ def recv(self, buffersize: int, flags: int | None = None) -> bytes: raise exc def true_sendall(self, data: bytes, *args: Any, **kwargs: Any) -> bytes: + """Send data through the real socket and receive response. + + Args: + data: Data to send + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + Response bytes from the real socket + + Raises: + StrictMocketException: If operation not allowed in STRICT mode + """ if not MocketMode.is_allowed(self._address): MocketMode.raise_not_allowed(self._address, data) @@ -344,7 +609,17 @@ def send( data: ReadableBuffer, *args: Any, **kwargs: Any, - ) -> int: # pragma: no cover + ) -> int: + """Send data through the socket. + + Args: + data: Data to send + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + Number of bytes sent + """ entry = self.get_entry(data) if not entry or (entry and self._entry != entry): kwargs["entry"] = entry @@ -357,7 +632,11 @@ def send( return len(data) def accept(self) -> tuple[MocketSocket, _RetAddress]: - """Accept a connection and return a new MocketSocket object.""" + """Accept a connection and return a new MocketSocket object. + + Returns: + Tuple of (new_socket, client_address) + """ new_socket = MocketSocket( family=self._family, type=self._type, @@ -369,11 +648,19 @@ def accept(self) -> tuple[MocketSocket, _RetAddress]: return new_socket, (self._host, self._port) def close(self) -> None: + """Close the socket and underlying true socket.""" if self._true_socket and not self._true_socket._closed: self._true_socket.close() def __getattr__(self, name: str) -> Any: - """Do nothing catchall function, for methods like shutdown()""" + """Do-nothing catchall function for methods like shutdown(). + + Args: + name: Method name + + Returns: + A callable that does nothing + """ def do_nothing(*args: Any, **kwargs: Any) -> Any: pass diff --git a/mocket/ssl/context.py b/mocket/ssl/context.py index 6d5e7307..aeaab6b5 100644 --- a/mocket/ssl/context.py +++ b/mocket/ssl/context.py @@ -1,3 +1,5 @@ +"""Mocket SSL context implementation.""" + from __future__ import annotations from typing import Any @@ -7,10 +9,13 @@ class _MocketSSLContext: - """For Python 3.6 and newer.""" + """Mock SSL context for Python 3.6 and newer.""" class FakeSetter(int): + """Descriptor that ignores assignment.""" + def __set__(self, *args: Any) -> None: + """Ignore any assignment attempts.""" pass minimum_version = FakeSetter() @@ -20,29 +25,49 @@ def __set__(self, *args: Any) -> None: class MocketSSLContext(_MocketSSLContext): - DUMMY_METHODS = ( + """Mock SSL context that wraps sockets in MocketSSLSocket.""" + + DUMMY_METHODS: tuple = ( "load_default_certs", "load_verify_locations", "set_alpn_protocols", "set_ciphers", "set_default_verify_paths", ) - sock = None - post_handshake_auth = None - _check_hostname = False + sock: MocketSocket | None = None + post_handshake_auth: bool | None = None + _check_hostname: bool = False @property def check_hostname(self) -> bool: + """Get the check_hostname setting. + + Returns: + Always False (mock implementation) + """ return self._check_hostname @check_hostname.setter def check_hostname(self, _: bool) -> None: + """Set the check_hostname setting (mocked). + + Args: + _: Value (ignored, always set to False) + """ self._check_hostname = False def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the SSL context. + + Args: + *args: Positional arguments (ignored) + **kwargs: Keyword arguments (ignored) + """ self._set_dummy_methods() def _set_dummy_methods(self) -> None: + """Set all dummy methods that do nothing.""" + def dummy_method(*args: Any, **kwargs: Any) -> Any: pass @@ -55,15 +80,36 @@ def wrap_socket( *args: Any, **kwargs: Any, ) -> MocketSSLSocket: + """Wrap a socket in an SSL socket. + + Args: + sock: Socket to wrap + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + MocketSSLSocket instance + """ return MocketSSLSocket._create(sock, *args, **kwargs) def wrap_bio( self, - incoming: Any, # _ssl.MemoryBIO - outgoing: Any, # _ssl.MemoryBIO + incoming: Any, + outgoing: Any, server_side: bool = False, server_hostname: str | bytes | None = None, ) -> MocketSSLSocket: + """Wrap BIO objects in an SSL socket (mock implementation). + + Args: + incoming: Incoming BIO (_ssl.MemoryBIO) + outgoing: Outgoing BIO (_ssl.MemoryBIO) + server_side: Whether this is server side + server_hostname: Server hostname + + Returns: + MocketSSLSocket instance + """ ssl_obj = MocketSSLSocket() ssl_obj._host = server_hostname return ssl_obj @@ -74,5 +120,15 @@ def mock_wrap_socket( *args: Any, **kwargs: Any, ) -> MocketSSLSocket: + """Mock ssl.wrap_socket function. + + Args: + sock: Socket to wrap + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + MocketSSLSocket instance + """ context = MocketSSLContext() return context.wrap_socket(sock, *args, **kwargs) diff --git a/mocket/ssl/socket.py b/mocket/ssl/socket.py index 6dcd7817..94984fce 100644 --- a/mocket/ssl/socket.py +++ b/mocket/ssl/socket.py @@ -1,3 +1,5 @@ +"""Mocket SSL socket implementation.""" + from __future__ import annotations import ssl @@ -12,14 +14,33 @@ class MocketSSLSocket(MocketSocket): + """Mock SSL socket that extends MocketSocket with SSL-specific behavior.""" + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize an SSL socket. + + Args: + *args: Positional arguments + **kwargs: Keyword arguments + """ super().__init__(*args, **kwargs) - self._did_handshake = False - self._sent_non_empty_bytes = False + self._did_handshake: bool = False + self._sent_non_empty_bytes: bool = False self._original_socket: MocketSocket = self def read(self, buffersize: int | None = None) -> bytes: + """Read data from the SSL socket. + + Args: + buffersize: Maximum bytes to read + + Returns: + Bytes read from the socket + + Raises: + ssl.SSLWantReadError: If handshake not completed and no data + """ rv = self.io.read(buffersize) if rv: self._sent_non_empty_bytes = True @@ -28,12 +49,29 @@ def read(self, buffersize: int | None = None) -> bytes: return rv def write(self, data: bytes) -> int | None: + """Write data to the SSL socket. + + Args: + data: Bytes to write + + Returns: + Number of bytes written + """ return self.send(encode_to_bytes(data)) def do_handshake(self) -> None: + """Perform SSL handshake (mock implementation).""" self._did_handshake = True def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: + """Get the peer certificate (mock implementation). + + Args: + binary_form: Whether to return binary form (unused) + + Returns: + Mock certificate dictionary + """ if not (self._host and self._port): self._address = self._host, self._port = Mocket._address @@ -54,12 +92,27 @@ def getpeercert(self, binary_form: bool = False) -> _PeerCertRetDictType: } def ciper(self) -> tuple[str, str, str]: + """Get cipher information (mock implementation). + + Returns: + Tuple of (cipher_name, protocol, key_exchange_algorithm) + """ return "ADH", "AES256", "SHA" def compression(self) -> Options: + """Get compression options (mock implementation). + + Returns: + SSL options constant + """ return ssl.OP_NO_COMPRESSION def unwrap(self) -> MocketSocket: + """Unwrap the SSL socket and return the underlying socket. + + Returns: + The original MocketSocket + """ return self._original_socket @classmethod @@ -71,6 +124,18 @@ def _create( *args: Any, **kwargs: Any, ) -> MocketSSLSocket: + """Create an SSL socket from a regular socket. + + Args: + sock: Socket to wrap + ssl_context: SSL context (optional) + server_hostname: Server hostname + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + New MocketSSLSocket instance + """ ssl_socket = MocketSSLSocket() ssl_socket._original_socket = sock ssl_socket._true_socket = sock._true_socket diff --git a/mocket/types.py b/mocket/types.py index 562648c7..fedfd37f 100644 --- a/mocket/types.py +++ b/mocket/types.py @@ -1,3 +1,5 @@ +"""Type aliases and definitions for Mocket.""" + from __future__ import annotations from typing import Any, Dict, Tuple, Union diff --git a/mocket/urllib3.py b/mocket/urllib3.py index e89bc7b5..872efc5f 100644 --- a/mocket/urllib3.py +++ b/mocket/urllib3.py @@ -1,3 +1,5 @@ +"""Urllib3 specific socket mocking.""" + from __future__ import annotations from typing import Any @@ -8,6 +10,14 @@ def mock_match_hostname(*args: Any) -> None: + """Mock urllib3's match_hostname function. + + Args: + *args: Ignored arguments + + Returns: + None + """ return None @@ -16,5 +26,15 @@ def mock_ssl_wrap_socket( *args: Any, **kwargs: Any, ) -> MocketSSLSocket: + """Mock urllib3's ssl_wrap_socket function. + + Args: + sock: The socket to wrap + *args: Additional arguments + **kwargs: Additional keyword arguments + + Returns: + MocketSSLSocket instance + """ context = MocketSSLContext() return context.wrap_socket(sock, *args, **kwargs) diff --git a/mocket/utils.py b/mocket/utils.py index 6180ae3f..749b2b70 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -1,3 +1,5 @@ +"""Utility functions for Mocket.""" + from __future__ import annotations import binascii @@ -14,12 +16,13 @@ class MocketizeDecorator(Protocol): - """ + """Protocol for a flexible decorator that can be used in multiple ways. + This is a generic decorator signature, currently applicable to get_mocketize. - Decorators can be used as: + Decorators implementing this protocol can be used as: 1. A function that transforms func (the parameter) into func1 (the returned object). - 2. A function that takes keyword arguments and returns 1. + 2. A function that takes keyword arguments and returns a decorator. """ @overload @@ -32,18 +35,37 @@ def __call__( def hexdump(binary_string: bytes) -> str: - r""" - >>> hexdump(b"bar foobar foo") == decode_from_bytes(encode_to_bytes("62 61 72 20 66 6F 6F 62 61 72 20 66 6F 6F")) - True + """Convert binary data to space-separated hex string. + + Args: + binary_string: Binary data to convert + + Returns: + Space-separated hexadecimal representation + + Example: + >>> hexdump(b"bar foobar foo") == decode_from_bytes(encode_to_bytes("62 61 72 20 66 6F 6F 62 61 72 20 66 6F 6F")) + True """ bs = decode_from_bytes(binascii.hexlify(binary_string).upper()) return " ".join(a + b for a, b in zip(bs[::2], bs[1::2])) def hexload(string: str) -> bytes: - r""" - >>> hexload("62 61 72 20 66 6F 6F 62 61 72 20 66 6F 6F") == encode_to_bytes("bar foobar foo") - True + """Convert space-separated hex string to binary data. + + Args: + string: Space-separated hexadecimal string + + Returns: + Binary data + + Raises: + ValueError: If the hex string is invalid + + Example: + >>> hexload("62 61 72 20 66 6F 6F 62 61 72 20 66 6F 6F") == encode_to_bytes("bar foobar foo") + True """ string_no_spaces = "".join(string.split()) try: @@ -53,6 +75,18 @@ def hexload(string: str) -> bytes: def get_mocketize(wrapper_: Callable) -> MocketizeDecorator: + """Get a mocketize decorator from a wrapper function. + + Decorators can be used as: + 1. A function that transforms func (the parameter) into func1 (the returned object). + 2. A function that takes keyword arguments and returns 1. + + Args: + wrapper_: The wrapper function to convert to a decorator + + Returns: + A MocketizeDecorator instance that can be used as a flexible decorator + """ # trying to support different versions of `decorator` with contextlib.suppress(TypeError): return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[return-value, call-arg, unused-ignore] diff --git a/tests/test_pook.py b/tests/test_pook.py index 56721b5f..012fcdfb 100644 --- a/tests/test_pook.py +++ b/tests/test_pook.py @@ -3,7 +3,6 @@ with contextlib.suppress(ModuleNotFoundError): import pook import requests - from mocket.plugins.pook_mock_engine import MocketEngine pook.set_mock_engine(MocketEngine) diff --git a/tests/test_socket.py b/tests/test_socket.py index dad62a33..68e71aee 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -1,4 +1,6 @@ import socket +import struct +from unittest.mock import MagicMock import pytest @@ -126,3 +128,22 @@ def test_recvfrom_into(): assert nbytes == len(test_data) assert buf[:nbytes] == test_data assert addr == sock._address + + +def test_setsockopt_without_optlen(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + sock._true_socket = MagicMock() + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock._true_socket.setsockopt.assert_called_once_with( + socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 + ) + + +def test_setsockopt_with_optlen(): + sock = MocketSocket(socket.AF_INET, socket.SOCK_STREAM) + sock._true_socket = MagicMock() + linger_value = struct.pack("ii", 1, 5) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, linger_value, len(linger_value)) + sock._true_socket.setsockopt.assert_called_once_with( + socket.SOL_SOCKET, socket.SO_LINGER, linger_value, len(linger_value) + ) From 0a035230f0de596cfa4441baf123f9f92a6f3084 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Sun, 22 Feb 2026 22:47:32 +0100 Subject: [PATCH 97/98] Bump version. --- mocket/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mocket/__init__.py b/mocket/__init__.py index 2103f97e..27ffad16 100644 --- a/mocket/__init__.py +++ b/mocket/__init__.py @@ -33,4 +33,4 @@ "FakeSSLContext", ) -__version__ = "3.14.0" +__version__ = "3.14.1" From b876453fde9bcedd11399f6ee39dfd25762b12d8 Mon Sep 17 00:00:00 2001 From: Aiudadadadf Date: Mon, 23 Feb 2026 19:40:24 +0100 Subject: [PATCH 98/98] tests: add tests for hexdump and hexload including ValueError on invalid hex (#320) --- tests/test_utils.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index d3b5eba7..a791d136 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import decorator -from mocket.utils import get_mocketize +from mocket.utils import get_mocketize, hexdump, hexload def mock_decorator(func: Callable[[], None]) -> None: @@ -29,3 +29,27 @@ def test_get_mocketize_without_kwsyntax(self, dec: NonCallableMock) -> None: dec.call_args_list[0].assert_compare_to((mock_decorator,), {"kwsyntax": True}) # Second time without kwsyntax, which succeeds dec.call_args_list[1].assert_compare_to((mock_decorator,)) + + +class HexdumpTestCase(TestCase): + def test_hexdump_converts_bytes_to_spaced_hex(self) -> None: + assert hexdump(b"Hi") == "48 69" + + def test_hexdump_empty_bytes(self) -> None: + assert hexdump(b"") == "" + + def test_hexdump_roundtrip_with_hexload(self) -> None: + data = b"bar foobar foo" + assert hexload(hexdump(data)) == data + + +class HexloadTestCase(TestCase): + def test_hexload_converts_spaced_hex_to_bytes(self) -> None: + assert hexload("48 69") == b"Hi" + + def test_hexload_empty_string(self) -> None: + assert hexload("") == b"" + + def test_hexload_invalid_hex_raises_value_error(self) -> None: + with self.assertRaises(ValueError): + hexload("ZZ ZZ")