From 4aab12568aece7b827ac078ef83201355f2f6467 Mon Sep 17 00:00:00 2001 From: Giorgio Salluzzo Date: Wed, 23 Jul 2025 15:46:19 +0200 Subject: [PATCH 1/6] 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 c9e6e20..2a21a0c 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 2/6] Update README.rst --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index 1c95e90..ad19ea6 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 3/6] Fix for GIT URLs. (#302) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ad19ea6..0e29c71 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 4/6] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0e29c71..04ac666 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 5/6] 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 4edd2b6..9eb1eca 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 3452a46..445a345 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 3db6a65..da1163b 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 afa3118..ab4057e 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 f8c8549..83bd38c 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 6/6] 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 04ac666..bc21ad9 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 eaf33df..8d30556 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 da1163b..50a6f95 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 34de793..fb40c0c 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 ab4057e..3d3e5b8 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 83bd38c..4685f4e 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 889a7df..add53de 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