Repository: h2non/pook

Issue:
Cannot pass `param`, `param_exists` or `params` kwarg to `Mock` constructor
Passing any of the query parameter settings to the `Mock` constructor results in the following error:

```
> pook.get(url="https://example.com", params={"a": "b"})
src/pook/api.py:342: in get
    return mock(url, method="GET", **kw)
src/pook/api.py:326: in mock
    return _engine.mock(url, **kw)
src/pook/engine.py:150: in mock
    mock = Mock(url=url, **kw)
src/pook/mock.py:130: in __init__
    trigger_methods(self, kw)
src/pook/helpers.py:41: in trigger_methods
    member(value)
src/pook/mock.py:355: in params
    url = furl(self._request.rawurl)
src/pook/request.py:81: in rawurl
    return self._url if isregex(self._url) else urlunparse(self._url)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

components = None

    def urlunparse(components):
        """Put a parsed URL back together again.  This may result in a
        slightly different, but equivalent URL, if the URL that was parsed
        originally had redundant delimiters, e.g. a ? with an empty query
        (the draft states that these are equivalent)."""
        scheme, netloc, url, params, query, fragment, _coerce_result = (
>                                                     _coerce_args(*components))
E       TypeError: urllib.parse._coerce_args() argument after * must be an iterable, not NoneType

/usr/lib64/python3.12/urllib/parse.py:515: TypeError
```

This is because `params` is called on the `Mock` instance by `trigger_methods` without a `url` necessarily being present. The implementation of `Mock::params` is such that a `url` _must_ be set on the request object before. This happens even if `url` is passed to the constructor because this line sorts the keys before iteration, causing `params` to get handled before `url`:

https://github.com/h2non/pook/blob/16ecba6cb2800c836e1bc399336dca1a49e66801/src/pook/helpers.py#L18

A better solution than sorting the list would be to keep a static list of the methods that should be executed in the order they're meant to be. For consistency, this should start with the alphabetical order to maintain the current behaviour, but include the fix to move `url` forward ahead of other methods that depend on it.

Ideally, the mock would be lazy in these cases and wait to process the param inputs until the URL is set. This would be more flexible overall anyway, as it would allow for the creation of a mock factory function in testing code that sets up params without the URL, and wouldn't behave in such an unexpected way.

Comments:

Patch:
diff --git a/src/pook/helpers.py b/src/pook/helpers.py
index 193c2c9..6af0685 100644
--- a/src/pook/helpers.py
+++ b/src/pook/helpers.py
@@ -1,8 +1,24 @@
+import re
+
 from inspect import ismethod, isfunction
 from .exceptions import PookInvalidArgument
 
 
-def trigger_methods(instance, args):
+reply_response_re = re.compile("^(response|reply)_")
+
+
+def _get_key(key_order):
+    def key(x):
+        raw = reply_response_re.sub("", x)
+        try:
+            return key_order.index(raw)
+        except KeyError:
+            raise PookInvalidArgument("Unsupported argument: {}".format(x))
+
+    return key
+
+
+def trigger_methods(instance, args, key_order=None):
     """
     Triggers specific class methods using a simple reflection
     mechanism based on the given input dictionary params.
@@ -10,18 +26,25 @@ def trigger_methods(instance, args):
     Arguments:
         instance (object): target instance to dynamically trigger methods.
         args (iterable): input arguments to trigger objects to
+        key_order (None|iterable): optional order in which to process keys; falls back to `sorted`'s default behaviour if not present
 
     Returns:
         None
     """
     # Start the magic
-    for name in sorted(args):
+    if key_order:
+        key = _get_key(key_order)
+        sorted_args = sorted(args, key=key)
+    else:
+        sorted_args = sorted(args)
+
+    for name in sorted_args:
         value = args[name]
         target = instance
 
         # If response attibutes
-        if name.startswith("response_") or name.startswith("reply_"):
-            name = name.replace("response_", "").replace("reply_", "")
+        if reply_response_re.match(name):
+            name = reply_response_re.sub("", name)
             # If instance has response attribute, use it
             if hasattr(instance, "_response"):
                 target = instance._response
diff --git a/src/pook/mock.py b/src/pook/mock.py
index 1c0316b..dd6fed6 100644
--- a/src/pook/mock.py
+++ b/src/pook/mock.py
@@ -96,6 +96,46 @@ class Mock(object):
         pook.Mock
     """
 
+    _KEY_ORDER = (
+        "add_matcher",
+        "body",
+        "callback",
+        "calls",
+        "content",
+        "delay",
+        "done",
+        "error",
+        "file",
+        "filter",
+        "header",
+        "header_present",
+        "headers",
+        "headers_present",
+        "isdone",
+        "ismatched",
+        "json",
+        "jsonschema",
+        "map",
+        "match",
+        "matched",
+        "matches",
+        "method",
+        "url",
+        "param",
+        "param_exists",
+        "params",
+        "path",
+        "persist",
+        "reply",
+        "response",
+        "status",
+        "times",
+        "total_matches",
+        "type",
+        "use",
+        "xml",
+    )
+
     def __init__(self, request=None, response=None, **kw):
         # Stores the number of times the mock should live
         self._times = 1
@@ -126,7 +166,7 @@ class Mock(object):
         self.callbacks = []
 
         # Triggers instance methods based on argument names
-        trigger_methods(self, kw)
+        trigger_methods(self, kw, self._KEY_ORDER)
 
         # Trigger matchers based on predefined request object, if needed
         if request:
diff --git a/src/pook/request.py b/src/pook/request.py
index 27e57d9..abd8733 100644
--- a/src/pook/request.py
+++ b/src/pook/request.py
@@ -44,7 +44,7 @@ class Request(object):
         self._extra = kw.get("extra")
         self._headers = HTTPHeaderDict()
 
-        trigger_methods(self, kw)
+        trigger_methods(self, kw, self.keys)
 
     @property
     def method(self):
diff --git a/src/pook/response.py b/src/pook/response.py
index 6cceae9..9effd56 100644
--- a/src/pook/response.py
+++ b/src/pook/response.py
@@ -23,6 +23,20 @@ class Response(object):
         mock (pook.Mock): reference to mock instance.
     """
 
+    _KEY_ORDER = (
+        "body",
+        "content",
+        "file",
+        "header",
+        "headers",
+        "json",
+        "mock",
+        "set",
+        "status",
+        "type",
+        "xml",
+    )
+
     def __init__(self, **kw):
         self._status = 200
         self._mock = None
@@ -31,7 +45,7 @@ class Response(object):
         self._headers = HTTPHeaderDict()
 
         # Trigger response method based on input arguments
-        trigger_methods(self, kw)
+        trigger_methods(self, kw, self._KEY_ORDER)
 
     def status(self, code=200):
         """


Test patch:
diff --git a/tests/unit/mock_test.py b/tests/unit/mock_test.py
index 4900d9c..4be7b79 100644
--- a/tests/unit/mock_test.py
+++ b/tests/unit/mock_test.py
@@ -1,6 +1,12 @@
 import pytest
+import json
+import re
+
+import pook
 from pook.mock import Mock
 from pook.request import Request
+from urllib.request import urlopen
+from urllib.parse import urlencode
 
 
 @pytest.fixture
@@ -17,6 +23,47 @@ def test_mock_url(mock):
     assert str(matcher(mock)) == "http://google.es"
 
 
+@pytest.mark.parametrize(
+    ("param_kwargs", "query_string"),
+    (
+        pytest.param({"params": {"x": "1"}}, "?x=1", id="params"),
+        pytest.param(
+            {"param": ("y", "pook")},
+            "?y=pook",
+            marks=pytest.mark.xfail(
+                condition=True,
+                reason="Constructor does not correctly handle multi-argument methods from kwargs",
+            ),
+            id="param",
+        ),
+        pytest.param(
+            {"param_exists": "z"},
+            # This complexity is needed until https://github.com/h2non/pook/issues/110
+            # is resolved
+            f'?{urlencode({"z": re.compile("(.*)")})}',
+            id="param_exists",
+        ),
+    ),
+)
+def test_constructor(param_kwargs, query_string):
+    # Should not raise
+    mock = Mock(
+        url="https://httpbin.org/404",
+        reply_status=200,
+        response_json={"hello": "from pook"},
+        **param_kwargs,
+    )
+
+    expected_url = f"https://httpbin.org/404{query_string}"
+    assert mock._request.rawurl == expected_url
+
+    with pook.use():
+        pook.engine().add_mock(mock)
+        res = urlopen(expected_url)
+        assert res.status == 200
+        assert json.loads(res.read()) == {"hello": "from pook"}
+
+
 @pytest.mark.parametrize(
     "url, params, req, expected",
     [