From dfc2ff5ec6c53cc74e994df4be5fa2b1ff3bb4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Pich=C3=A9?= Date: Fri, 27 Oct 2023 08:32:56 -0400 Subject: [PATCH 1/3] make a package out of coveo-testing/ref --- .github/workflows/coveo-ref.yml | 62 +++ coveo-ref/README.md | 441 ++++++++++++++++ coveo-ref/coveo_ref/__init__.py | 395 ++++++++++++++ coveo-ref/coveo_ref/exceptions.py | 22 + coveo-ref/coveo_ref/py.typed | 0 coveo-ref/poetry.lock | 487 ++++++++++++++++++ coveo-ref/pyproject.toml | 35 ++ coveo-ref/pytest.ini | 2 + coveo-ref/tests_coveo_ref/__init__.py | 0 coveo-ref/tests_coveo_ref/conftest.py | 9 + .../tests_coveo_ref/mock_module/__init__.py | 26 + .../tests_coveo_ref/mock_module/inner.py | 51 ++ .../mock_module/shadow_rename.py | 5 + coveo-ref/tests_coveo_ref/py.typed | 0 coveo-ref/tests_coveo_ref/test_mocks.py | 341 ++++++++++++ pyproject.toml | 1 + 16 files changed, 1877 insertions(+) create mode 100644 .github/workflows/coveo-ref.yml create mode 100644 coveo-ref/README.md create mode 100644 coveo-ref/coveo_ref/__init__.py create mode 100644 coveo-ref/coveo_ref/exceptions.py create mode 100644 coveo-ref/coveo_ref/py.typed create mode 100644 coveo-ref/poetry.lock create mode 100644 coveo-ref/pyproject.toml create mode 100644 coveo-ref/pytest.ini create mode 100644 coveo-ref/tests_coveo_ref/__init__.py create mode 100644 coveo-ref/tests_coveo_ref/conftest.py create mode 100644 coveo-ref/tests_coveo_ref/mock_module/__init__.py create mode 100644 coveo-ref/tests_coveo_ref/mock_module/inner.py create mode 100644 coveo-ref/tests_coveo_ref/mock_module/shadow_rename.py create mode 100644 coveo-ref/tests_coveo_ref/py.typed create mode 100644 coveo-ref/tests_coveo_ref/test_mocks.py diff --git a/.github/workflows/coveo-ref.yml b/.github/workflows/coveo-ref.yml new file mode 100644 index 00000000..e9e008e2 --- /dev/null +++ b/.github/workflows/coveo-ref.yml @@ -0,0 +1,62 @@ +name: coveo-testing + +on: + push: + branches: + - main + paths: + - 'coveo-ref/**' + + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'coveo-ref/**' + + workflow_dispatch: + inputs: + publish: + description: "Publish to pypi.org?" + required: false + default: 'false' + + +jobs: + stew-ci: + name: pyproject ci + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + python-version: [3.8, "3.10"] + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Run stew ci + uses: coveo/stew@main + with: + project-name: coveo-ref + python-version: ${{ matrix.python-version }} + poetry-version: "<2" + + publish: + name: Publish to pypi.org + runs-on: ubuntu-latest + needs: stew-ci + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup python 3.10 + uses: actions/setup-python@v4 + with: + python-version: 3.10 + + - name: Publish to pypi + uses: ./.github/workflows/actions/publish-to-pypi + with: + project-name: coveo-ref + pypi-token: ${{ secrets.PYPI_TOKEN_COVEO_REF }} + pre-release: ${{ github.ref != 'refs/heads/main' }} + dry-run: ${{ github.ref != 'refs/heads/main' && github.event.inputs.publish != 'true' }} diff --git a/coveo-ref/README.md b/coveo-ref/README.md new file mode 100644 index 00000000..944676f1 --- /dev/null +++ b/coveo-ref/README.md @@ -0,0 +1,441 @@ +# coveo-ref + +Refactorable mock targets + +## Demo + +Consider this common piece of code: + +```python +from unittest.mock import patch, MagicMock + +@patch("mymodule.clients.APIClient._do_request") +def test(api_client_mock: MagicMock) -> None: + ... +``` + +Because the mock target is a string, it makes it difficult to move things around without breaking the tests. You need a +tool that can extract the string representation of a python objet. This is what `ref` was built for: + +```python +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref +from mymodule.clients import APIClient + +@patch(*ref(APIClient._do_request)) +def test(api_client_mock: MagicMock) -> None: + ... +``` + +🚀 This way, you can rename or move `mymodule`, `clients`, `APIClient` or even `_do_request`, and your IDE should find +these and adjust them just like any other reference in your project. + +Let's examine a more complex example: + +```python +from unittest.mock import patch, MagicMock +from mymodule.tasks import process + +@patch("mymodule.tasks.get_api_client") +def test(get_api_client_mock: MagicMock) -> None: + assert process() is None # pretend this tests the process function +``` + +The interesting thing in this example is that we're mocking `get_api_client` in the `tasks` module. +Let's take a look at the `tasks` module: + +```python +from typing import Optional +from mymodule.clients import get_api_client + +def process() -> Optional[bool]: + client = get_api_client() + return ... +``` + +As we can see, `get_api_client` is defined in another module. +The test needs to patch the function _in the tasks module_ since that's the context it will be called from. +Unfortunately, inspecting `get_api_client` from the `tasks` module at runtime leads us back to `mymodule.clients`. + +This single complexity means that hardcoding the context `mymodule.tasks` and symbol `get_api_client` into a string +for the patch is the straightforward solution. + +But with `ref`, you specify the context separately: + +```python +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref +from mymodule.clients import get_api_client +from mymodule.tasks import process + + +@patch(*ref(get_api_client, context=process)) +def test(get_api_client_mock: MagicMock) -> None: + assert process() is None # pretend this tests the process function +``` + +🚀 By giving a context to `ref`, the symbol `get_api_client` will be resolved from the context of `process`, which is the +`mymodule.tasks` module. The result is `mymodule.tasks.get_api_client`. + +If either objects (`get_api_client` or `process`) are moved or renamed using a refactoring tool, the mock will still +point to the correct name and context. + +🚀 And a nice bonus is that your IDE can jump to `get_api_client`'s definition from the test file now! + +It should be noted that this isn't just some string manipulation. `ref` will import and inspect modules and objects +to make sure that they're correct. Here's a more complex case with a renamed symbol: + +The module: + +```python +from typing import Optional +from mymodule.clients import get_api_client as client_factory # it got renamed! 😱 + +def process() -> Optional[bool]: + client = client_factory() + return ... +``` + +The test: + +```python +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref +from mymodule.clients import get_api_client +from mymodule.tasks import process + + +@patch(*ref(get_api_client, context=process)) +def test(get_api_client_mock: MagicMock) -> None: + assert process() is None # pretend this tests the process function +``` + +Notice how the test and patch did not change despite the renamed symbol? + +🚀 This is because `ref` will find `get_api_client` as `client_factory` when inspecting `mymodule.tasks` module, +and return `mymodule.tasks.client_factory`. + +We can also use ref with `patch.object()` in order to patch a single instance. Consider the following code: + +```python +from unittest.mock import patch +from mymodule.clients import APIClient + +def test() -> None: + client = APIClient() + with patch.object(client, "_do_request"): + ... +``` + +🚀 By specifying `obj=True` to `ref`, you will obtain a `Tuple[instance, attribute_to_patch_as_a_string]` that you +can unpack to `patch.object()`: + +```python +from unittest.mock import patch +from coveo_testing.mocks import ref +from mymodule.clients import APIClient + +def test() -> None: + client = APIClient() + with patch.object(*ref(client._do_request, obj=True)): + ... +``` + +Please refer to the docstring of `ref` for argument usage information. + +## Common Mock Recipes + +### Mock something globally without context +#### Option 1: by leveraging the import mechanism + +To mock something globally without regards for the context, it has to be accessed through a dot `.` by the context. + +For instance, consider this test: + +```python +from http.client import HTTPResponse +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref + +from mymodule.tasks import process + + +@patch(*ref(HTTPResponse.close)) +def test(http_response_close_mock: MagicMock) -> None: + assert process() +``` + +The target is `HTTPResponse.close`, which lives in the `http.client` module. +The contextof the test is the `process` function, which lives in the `mymodule.tasks` module. +Let's take a look at `mymodule.tasks`'s source code: + + +```python +from http import client + +def process() -> bool: + _ = client.HTTPResponse(...) # of course this is fake, but serves the example + return ... +``` + +Since `mymodule.tasks` reaches `HTTPResponse` through a dot (i.e.: `client.HTTPResponse`), we can patch `HTTPResponse` +without using `mymodule.tasks` as the context. + +However, if `mymodule.tasks` was written like this: + +```python +from http.client import HTTPResponse + +def process() -> bool: + _ = HTTPResponse(...) + return ... +``` + +Then the patch would not affect the object used by the `process` function anymore. However, it would affect any other +module that uses the dot to reach `HTTPResponse` since the patch was _still_ applied globally. + + +#### Option 2: By wrapping a hidden function + +Another approach to mocking things globally is to hide a function behind another, and mock the hidden function. +This allows modules to use whatever import style they want, and the mocks become straightforward to setup. + +Pretend this is `mymodule.clients`: + +```python +class APIClient: + ... + +def get_api_client() -> APIClient: + return _get_api_client() + +def _get_api_client() -> APIClient: + return APIClient() +``` + +And this is `mymodule.tasks`: + +```python +from mymodule.clients import get_api_client + +def process() -> bool: + return get_api_client() is not None +``` + +So you _know_ this works globally, because no one will (should?) import the private one except the test: + +```python +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref + +from mymodule.tasks import process +from mymodule.clients import _get_api_client + + +@patch(*ref(_get_api_client)) +def test(api_client_mock: MagicMock) -> None: + assert process() +``` + + +### Mock something for a given context + +When you want to mock something for a given module, you must provide a hint to `ref` as the `context` argument. + +The hint may be a module, or a function/class defined within that module. "Defined" here means that "def" or "class" +was used _in that module_. If the hint was imported into the module, it will not work: + +`mymodule.tasks`: + +```python +from mymodule.clients import get_api_client + +def process() -> bool: + client = get_api_client() + return ... +``` + +The test, showing 3 different methods that work: + +```python +from unittest.mock import patch, MagicMock +from coveo_testing.mocks import ref + +from mymodule.clients import get_api_client +from mymodule.tasks import process + +# you can pass the module as the context +import mymodule + +@patch(*ref(get_api_client, context=mymodule.tasks)) +def test(get_api_client_mock: MagicMock) -> None: + assert process() + +# you can pass the module as the context, version 2 +from mymodule import tasks + +@patch(*ref(get_api_client, context=tasks)) +def test(get_api_client_mock: MagicMock) -> None: + assert process() + +# you can also pass a function or a class defined in the `tasks` module +from mymodule.tasks import process +@patch(*ref(get_api_client, context=process)) +def test(get_api_client_mock: MagicMock) -> None: + assert process() +``` + +The 3rd method is encouraged: provide the function or class that is actually using the `get_api_client` import. +In our example, that's the `process` function. +If `process` was ever moved to a different module, it would carry the `get_api_client` import, and the mock would +be automatically adjusted to target `process`'s new module without changes. + +### Mock something for the current context + +Sometimes, the test file _is_ the context. When that happens, just pass `__name__` as the context: + +```python +from unittest.mock import patch +from coveo_testing.mocks import ref +from mymodule.clients import get_api_client, APIClient + +def _prepare_test() -> APIClient: + client = get_api_client() + ... + return client + +@patch(*ref(get_api_client, context=__name__)) +def test() -> None: + client = _prepare_test() + ... +``` + + +### Mock a method on a class + +Since a method cannot be imported and can only be accessed through the use of a dot `.` on a class or instance, +you can always patch methods globally: + +```python +with patch(*ref(MyClass.fn)): ... +``` + +This is because no module can import `fn`; it has to go through an import of `MyClass`. + +### Mock a method on one instance of a class + +Simply add `obj=True` and use `patch.object()`: + +```python +with patch.object(*ref(instance.fn, obj=True)): ... +``` + + +### Mock an attribute on a class/instance/module/function/object/etc + +`ref` cannot help with this task: +- You cannot refer an attribute that exists (you would pass the value, not a reference) +- You cannot refer an attribute that doesn't exist (because it doesn't exist!) + +For this, there's no going around hardcoding the attribute name in a string: + +```python +class MyClass: + def __init__(self) -> None: + self.a = 1 + + +def test_attr() -> None: + instance = MyClass() + with patch.object(instance, "a", new=2): + assert instance.a == 2 + assert MyClass().a == 1 +``` + +This sometimes work when patching **instances**. +The example works because `a` is a simple attribute that lives in `instance.__dict__` and `patch.object` knows +about that. + +But if you tried to patch `MyClass` instead of `instance`, `mock.patch` would complain that there's no +such thing as `a` over there. +Thus, patching an attribute globally will most likely result in a lot of wasted time, and should be avoided. + +There's no way to make the example work with `ref` because there's no way to refer `instance.a` without actually +getting the value of `a`, unless we hardcode a string, which defeats the purpose of `ref` completely. + + +### Mock a property + +You can only patch a property globally, through its class: + +```python +class MyClass: + @property + def get(self) -> bool: + return False +``` + +```python +from unittest.mock import PropertyMock, patch, MagicMock +from coveo_testing.mocks import ref + +from mymodule import MyClass + +@patch(*ref(MyClass.get), new_callable=PropertyMock, return_value=True) +def test(my_class_get_mock: MagicMock) -> None: + assert MyClass().get == True + my_class_get_mock.assert_called_once() +``` + +You **cannot** patch a property on an instance, this is a limitation of `unittest.mock` because of the way +properties work. +If you try, `mock.patch.object()` will complain that the property is read only. + + +### Mock a classmethod or staticmethod on a specific instance + +When inspecting these special methods on an instance, `ref` ends up finding the class instead of the instance. + +Therefore, `ref` is unable to return a `Tuple[instance, function_name]`. +It would return `Tuple[class, function_name]`, resulting in a global patch. 😱 + +But `ref` will detect this mistake, and will raise a helpful exception if it cannot return an instance when you +specified `obj=True`. + +For this particular scenario, the workaround is to provide the instance as the context: + +```python +from unittest.mock import patch +from coveo_testing.mocks import ref + + +class MyClass: + @staticmethod + def get() -> bool: + return False + + +def test() -> None: + instance = MyClass() + with patch.object(*ref(instance.get, context=instance, obj=True)) as fn_mock: + assert instance.get == True + assert MyClass().get == False # new instances are not affected by the object mock + fn_mock.assert_called_once() +``` + +Some may prefer a more semantically-correct version by specifying the target through the class instead of the +instance. In the end, these are all equivalent: + +```python +with patch.object(instance, "get"): + ... + +with patch.object(*ref(instance.get, context=instance, obj=True)): + ... + +with patch.object(*ref(MockClass.get, context=instance, obj=True)): + ... +``` + +In this case, the version without ref is much shorter and arguably more pleasant for the eye, but `get` can no longer +be renamed without altering the tests. diff --git a/coveo-ref/coveo_ref/__init__.py b/coveo-ref/coveo_ref/__init__.py new file mode 100644 index 00000000..1aa5c130 --- /dev/null +++ b/coveo-ref/coveo_ref/__init__.py @@ -0,0 +1,395 @@ +import importlib +import inspect +from dataclasses import dataclass +from textwrap import dedent +from types import ModuleType +from typing import Any, Literal, Optional, Sequence, Tuple, Union, overload +from unittest.mock import Mock + +from coveo_ref.exceptions import ( + CannotImportModule, + CannotFindSymbol, + NoQualifiedName, + DuplicateSymbol, + UsageError, +) + + +@dataclass(frozen=True) +class _PythonReference: + """A helper class around resolving and importing python symbols.""" + + module_name: str + symbol_name: Optional[str] = None + attributes: Optional[str] = None # may have dots, such as NestedClass.attribute + + def __str__(self) -> str: + return self.fully_qualified_name + + @property + def fully_qualified_name(self) -> str: + """Returns the fully dotted name, such as `tests_testing.mock_module.inner.MockClass.inner_function`""" + return ".".join(filter(bool, (self.module_name, self.symbol_name, self.attributes))) + + @property + def attributes_split(self) -> Sequence[str]: + """Returns the attribute name as a nested sequence""" + if not self.attributes: + return () + + return self.attributes.split(".") + + @property + def nested_attributes(self) -> Sequence[str]: + """Returns the nested attributes, if any. Doesn't include the last attribute.""" + return self.attributes_split[:-1] + + @property + def last_attribute(self) -> Optional[str]: + """ + The last attribute name is the last attribute in the nested attributes of a symbol. + Not all references have an attribute name. + + e.g.: + - `attribute` in module.MyClass.NestedClass.attribute + """ + if not self.attributes: + return None + + return self.attributes[-1] + + def import_module(self) -> ModuleType: + """Import and return the module.""" + try: + return importlib.import_module(self.module_name) + except ImportError as exception: + raise CannotImportModule( + f"{__name__} tried to resolve {self} but was unable to import {self.module_name=}", + name=self.module_name, + ) from exception + + def import_symbol(self) -> Any: + """Import and return the symbol. For modules, the module is returned.""" + module = self.import_module() + + try: + return getattr(module, self.symbol_name) if self.symbol_name else module + except AttributeError as exception: + raise CannotFindSymbol( + f"{__name__} tried to resolve {self}, but could not find {self.symbol_name=} in {self.module_name=}" + ) from exception + + def import_nested_symbol(self) -> Any: + """ + Imports the symbol, walk any nested attributes and return the symbol holding the last attribute. + If no nested attributes exist, the main symbol is returned. + + e.g.: + - `DoubleNestedClass` in module.MyClass.NestedClass.DoubleNestedClass.attribute + - `MyClass` in module.MyClass.attribute + """ + symbol = self.import_symbol() + for nested_attribute in self.nested_attributes: + try: + symbol = getattr(symbol, nested_attribute) + except AttributeError as exception: + raise CannotFindSymbol( + f"{__name__} tried to resolve {self} but could not find {nested_attribute=} in {symbol=}" + ) from exception + return symbol + + def with_module(self, module: Union[str, ModuleType]) -> "_PythonReference": + """Returns a new instance of _PythonReference that targets the same symbol in a different module.""" + return _PythonReference( + module_name=module if isinstance(module, str) else module.__name__, + symbol_name=self.symbol_name, + attributes=self.attributes, + ) + + def with_symbol(self, symbol_name: str) -> "_PythonReference": + """Returns a new instance of _PythonReference that targets a different symbol.""" + return _PythonReference( + module_name=self.module_name, + symbol_name=symbol_name, + attributes=self.attributes, + ) + + def with_attributes( + self, *attributes: str, keep_nested_attributes: bool = False + ) -> "_PythonReference": + """Returns a new instance of _PythonReference that targets a different attribute.""" + if keep_nested_attributes: + attributes = *self.nested_attributes, *attributes + + return _PythonReference( + module_name=self.module_name, + symbol_name=self.symbol_name, + attributes=".".join(attributes), + ) + + @classmethod + def from_any(cls, obj: Any) -> "_PythonReference": + """ + Returns a _PythonReference based on an object. + + If obj is a string, it will be imported as is; therefore, it has to be a fully qualified, importable symbol, + and thus cannot contain attributes. + """ + try: + return cls._from_any(obj) + except NoQualifiedName: + if hasattr(obj, "__class__") and hasattr(obj.__class__, "__qualname__"): + # this is most likely an instance; report the class. + return cls.from_any(obj.__class__) + raise + + @classmethod + def _from_any(cls, obj: Any) -> "_PythonReference": + if isinstance(obj, str): + return cls.from_any(importlib.import_module(obj)) + + if inspect.ismodule(obj): + return cls(module_name=obj.__name__) + + if isinstance(obj, property): + return cls.from_property(obj) + + try: + qualifiers = obj.__qualname__.split(".") + except AttributeError as exception: + raise NoQualifiedName(f"New use case? {obj} cannot be resolved.") from exception + + return cls.from_qualifiers(obj.__module__, *qualifiers) + + @classmethod + def from_property(cls, prop: property) -> "_PythonReference": + """Returns a _PythonReference based on a property.""" + # Unlike functions, properties are naive and don't hold a link back to the class that holds them. + # The black magic contained in this vial will inspect the setter and getter, which are assumed to be functions + # that are defined on the same class as the property. We then fish that class to find the correct attribute that + # contains our property. + for fn in filter(bool, (prop.fget, prop.fset)): + try: + qualifiers = fn.__qualname__.split(".") + except AttributeError: + continue # a potential edge case; the setters/getters could be a weird object. + + if len(qualifiers) == 1: + continue # if the function is attached to a module, we can't find the owner + + fn_reference = cls.from_qualifiers(fn.__module__, *qualifiers) + symbol = fn_reference.import_nested_symbol() + + # try a direct hit first + if symbol.__dict__.get(fn_reference.last_attribute) is prop: + return fn_reference + + # fish for identity + attributes_with_prop = tuple( + attribute_name for attribute_name, obj in symbol.__dict__.items() if obj is prop + ) + + if len(attributes_with_prop) == 1: + return fn_reference.with_attributes( + attributes_with_prop[0], keep_nested_attributes=True + ) + + if len(attributes_with_prop) > 1: + raise DuplicateSymbol( + f"Ambiguous match: {prop} was found in multiple attributes of {symbol}: {attributes_with_prop=}" + ) + + raise CannotFindSymbol(f"Cannot find the owner of {prop} by inspecting its getter/setter.") + + @classmethod + def from_qualifiers(cls, module_name: str, *qualifiers: str) -> "_PythonReference": + """Return a _PythonReference based on the module name and qualifiers.""" + if any("<" in qualifier for qualifier in (module_name, *qualifiers)): + # for instance, a lambda has a __qualname__ of `` and generator expressions have ``. + raise CannotFindSymbol( + f"One of the qualifiers in {module_name} or {qualifiers} is a special object that cannot be resolved." + ) + + if not any(qualifiers): + return cls(module_name=module_name) + + # it's possible that qualifiers[1:] contains nested modules; `importable` in this case is the symbol we will + # be using `getattr` on later in order to "walk" the rest of the qualifiers. + importable = qualifiers[0] + + if len(qualifiers) == 1: + # this is a symbol defined at the module level + return cls(module_name=module_name, symbol_name=importable, attributes=None) + + # this is an attribute on a symbol + return cls( + module_name=module_name, symbol_name=importable, attributes=".".join(qualifiers[1:]) + ) + + +def _translate_reference_to_another_module( + reference: _PythonReference, module: Union[ModuleType, str] +) -> _PythonReference: + """ + Return a reference to the object pointed to by `reference`, in the context of how it was imported by `module`. + + For instance: + - Reference A is defined in module B + - Module C calls `from B import A as RenamedA` + - Calling with (reference=A, module=C) will return a reference to "C.RenamedA" + """ + symbol_to_find = reference.import_symbol() + new_reference = reference.with_module(module if isinstance(module, str) else module.__name__) + + try: + imported = new_reference.import_symbol() + if imported is not symbol_to_find: + raise CannotFindSymbol( + f"Importing {new_reference} resulted in {imported} but {symbol_to_find} was expected." + ) + except CannotFindSymbol: + # symbol was renamed? fish! + module = new_reference.import_module() + symbols_found = tuple( + symbol_name + for symbol_name, symbol in module.__dict__.items() + if symbol is symbol_to_find + ) + + if not symbols_found: + raise # reraise CannotFindSymbol + + if len(symbols_found) > 1: + raise DuplicateSymbol( + f"Duplicate symbols found for {symbol_to_find} in {module.__name__}: {symbols_found=}" + ) + + return new_reference.with_symbol(symbols_found[0]) + + return new_reference + + +def resolve_mock_target(target: Any) -> str: + """ + + Deprecated: You are encouraged to use `ref` instead, which can resolve a name in a target module. + + --- + Deprecated docs: + + `mock.patch` uses a str-representation of an object to find it, but this doesn't play well with + refactors and renames. This method extracts the str-representation of an object. + + This method will not handle _all_ kinds of objects, in which case an AttributeError will most likely be raised. + """ + return f"{target.__module__}.{target.__name__}" + + +@overload +def ref(target: Any) -> Tuple[str]: + ... + + +@overload +def ref(target: Any, *, context: Optional[Any]) -> Tuple[str]: + ... + + +@overload +def ref(target: Any, *, obj: Literal[False]) -> Tuple[str]: + ... + + +@overload +def ref(target: Any, *, context: Optional[Any], obj: Literal[False]) -> Tuple[str]: + ... + + +@overload +def ref(target: Any, *, obj: Literal[True]) -> Tuple[Any, str]: + ... + + +@overload +def ref(target: Any, *, context: Any, obj: Literal[True]) -> Tuple[Any, str]: + ... + + +def ref( + target: Any, + *, + context: Optional[Any] = None, + obj: bool = False, + _bypass_context_check: bool = False, +) -> Union[Tuple[str], Tuple[Any, str]]: + """ + Replaces `resolves_mock_target`. Named for brevity. + + Returns a tuple meant to be unpacked into the `mock.patch` or `mock.patch.object` functions in order to enable + refactorable mocks. + + The idea is to provide the thing to mock as the target, and sometimes, the thing that is being tested + as the context. Refer to `coveo-testing`'s readme to better understand when a context is necessary. + + For example, pass the `HTTPResponse` class as the target and the `my_module.function_to_test` function + as the context, so that `my_module.HTTPResponse` becomes mocked (and not httplib.client.HTTPResponse). + + The readme in this repository offers a lot of explanations, examples and recipes on how to mock things properly and + when we don't need to provide a `context` argument. + + -- param: context + In order to target the module where the mock will be used, use the `context` argument. It can be either: + - A module name as a string (e.g.: "coveo_testing.logging", but more importantly, __name__) + - A symbol that belongs to the module to patch (i.e.: any function or class defined in that module) + - An instance, when patching special functions with `obj=True` + + e.g.: mock.patch(*ref(boto3, context=function_that_uses_boto3)) + + -- param: obj + In order to patch a single instance with `patch.object`, specify `obj=True`: + + e.g.: mock.patch.object(*ref(instance.fn, obj=True)) + """ + if isinstance(target, Mock) or isinstance(context, Mock): + raise UsageError("Mocks cannot be resolved.") + + source_reference = _PythonReference.from_any(target) + + if obj: + # Not having an attribute name would be an error for `mock.patch.object` anyway. + if not source_reference.attributes: + raise UsageError( + f"Patching an object requires at least one attribute: {source_reference}" + ) + + if context is None: + # normal functions link back to their instance through the __self__ dunder; such convenience! + context = getattr(target, "__self__", None) + + if (context is None or isinstance(context, type)) and not _bypass_context_check: + raise UsageError( + dedent( + f""" + Cannot resolve an instance for the context: this is important because {obj=} was specified. + Applying this patch would most likely result in a global patch, contradicting the intent of {obj=}. + + If you are trying to patch a classmethod or staticmethod on a specific instance, you must provide that + instance as the `context` argument. + + If the goal was to patch globally, remove the {obj=} argument, optionally provide a context + and use patch(). + + If you believe this is a mistake, you can try to use `_bypass_context_check` and see if it works. + If it does, please submit an issue with a quick test that reproduces the issue! <3 + """ + ) + ) + + return context, source_reference.attributes + + context_reference = _PythonReference.from_any(context or target) + target_reference = _translate_reference_to_another_module( + source_reference, context_reference.module_name + ) + + return (target_reference.fully_qualified_name,) diff --git a/coveo-ref/coveo_ref/exceptions.py b/coveo-ref/coveo_ref/exceptions.py new file mode 100644 index 00000000..4bbf3d68 --- /dev/null +++ b/coveo-ref/coveo_ref/exceptions.py @@ -0,0 +1,22 @@ +class RefException(Exception): + """Base class for ref exceptions.""" + + +class UsageError(RefException): + """When ref detects usage errors.""" + + +class NoQualifiedName(RefException, NotImplementedError): + """When something has apparently no qualified name.""" + + +class CannotImportModule(RefException, ImportError): + """Occurs when an import fails.""" + + +class CannotFindSymbol(RefException, AttributeError): + """Occurs when a symbol cannot be imported from a module.""" + + +class DuplicateSymbol(CannotFindSymbol): + """Occurs when a symbol occurs more than once within a module.""" diff --git a/coveo-ref/coveo_ref/py.typed b/coveo-ref/coveo_ref/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/coveo-ref/poetry.lock b/coveo-ref/poetry.lock new file mode 100644 index 00000000..b9a0a176 --- /dev/null +++ b/coveo-ref/poetry.lock @@ -0,0 +1,487 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] + +[[package]] +name = "bandit" +version = "1.7.5" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.7" +files = [ + {file = "bandit-1.7.5-py3-none-any.whl", hash = "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549"}, + {file = "bandit-1.7.5.tar.gz", hash = "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "tomli (>=1.1.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + +[[package]] +name = "black" +version = "23.10.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, + {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, + {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, + {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, + {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, + {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, + {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, + {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, + {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, + {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, + {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, + {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coveo-testing" +version = "2.0.11" +description = "Lightweight testing helpers" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coveo_testing-2.0.11-py3-none-any.whl", hash = "sha256:885a8ccf49bd0eef69908e70b8ee4b232e9a744977cf0dd7591a9ea193558909"}, + {file = "coveo_testing-2.0.11.tar.gz", hash = "sha256:6baa7fc45bddf2fde8c64570fc840ade71bfb183840ccea1e5ee6785e1c4ee24"}, +] + +[package.dependencies] +attrs = "*" +pytest = "*" + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "gitdb" +version = "4.0.11" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.40" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, + {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[package.extras] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "0.950" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mypy-0.950-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b"}, + {file = "mypy-0.950-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0"}, + {file = "mypy-0.950-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22"}, + {file = "mypy-0.950-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb"}, + {file = "mypy-0.950-cp310-cp310-win_amd64.whl", hash = "sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334"}, + {file = "mypy-0.950-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f"}, + {file = "mypy-0.950-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc"}, + {file = "mypy-0.950-cp36-cp36m-win_amd64.whl", hash = "sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2"}, + {file = "mypy-0.950-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed"}, + {file = "mypy-0.950-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075"}, + {file = "mypy-0.950-cp37-cp37m-win_amd64.whl", hash = "sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b"}, + {file = "mypy-0.950-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d"}, + {file = "mypy-0.950-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a"}, + {file = "mypy-0.950-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605"}, + {file = "mypy-0.950-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2"}, + {file = "mypy-0.950-cp38-cp38-win_amd64.whl", hash = "sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff"}, + {file = "mypy-0.950-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8"}, + {file = "mypy-0.950-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038"}, + {file = "mypy-0.950-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2"}, + {file = "mypy-0.950-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519"}, + {file = "mypy-0.950-cp39-cp39-win_amd64.whl", hash = "sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef"}, + {file = "mypy-0.950-py3-none-any.whl", hash = "sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb"}, + {file = "mypy-0.950.tar.gz", hash = "sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de"}, +] + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "pbr" +version = "5.11.1" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, + {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, +] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pytest" +version = "7.4.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.6.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.6.0-py3-none-any.whl", hash = "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245"}, + {file = "rich-13.6.0.tar.gz", hash = "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "smmap" +version = "5.0.1" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.7" +files = [ + {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, + {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, +] + +[[package]] +name = "stevedore" +version = "5.1.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, + {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8" +content-hash = "648db4cc9d051b70f4585b166f20ba7e765a49cb8a78b1c0df5c5d0408b8e43f" diff --git a/coveo-ref/pyproject.toml b/coveo-ref/pyproject.toml new file mode 100644 index 00000000..db36980b --- /dev/null +++ b/coveo-ref/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "coveo-ref" +version = "1.0.0" +description = "Allows using unittest.patch() without hardcoding strings." +license = "Apache-2.0" +readme = "README.md" +repository = "https://github.com/coveooss/coveo-python-oss/tree/main/coveo-ref" +authors = ["Jonathan Piché "] + +[tool.poetry.dependencies] +python = ">=3.8" + + +[tool.poetry.dev-dependencies] +bandit = "*" +black = "*" +coveo-testing = "*" +mypy = "0.950" +pytest = "*" + + + +[tool.stew.ci] +pytest = true +offline-build = true +black = true + + +[tool.black] +line-length = 100 + + +[build-system] +requires = ["poetry_core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/coveo-ref/pytest.ini b/coveo-ref/pytest.ini new file mode 100644 index 00000000..fe55d2ed --- /dev/null +++ b/coveo-ref/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +junit_family=xunit2 diff --git a/coveo-ref/tests_coveo_ref/__init__.py b/coveo-ref/tests_coveo_ref/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/coveo-ref/tests_coveo_ref/conftest.py b/coveo-ref/tests_coveo_ref/conftest.py new file mode 100644 index 00000000..5050167a --- /dev/null +++ b/coveo-ref/tests_coveo_ref/conftest.py @@ -0,0 +1,9 @@ +"""pytest bootstrap""" + +from _pytest.config import Config +from coveo_testing.markers import register_markers + + +def pytest_configure(config: Config) -> None: + """This pytest hook is ran once, before collecting tests.""" + register_markers(config) diff --git a/coveo-ref/tests_coveo_ref/mock_module/__init__.py b/coveo-ref/tests_coveo_ref/mock_module/__init__.py new file mode 100644 index 00000000..f3ec9f8e --- /dev/null +++ b/coveo-ref/tests_coveo_ref/mock_module/__init__.py @@ -0,0 +1,26 @@ +from tests_coveo_ref.mock_module.inner import ( + MockClass, + inner_function, + inner_function_wrapper, + MockClassToRename as RenamedClass, +) + + +def call_inner_function_from_another_module() -> str: + return inner_function() + + +def call_inner_function_wrapper_from_another_module() -> str: + return inner_function_wrapper() + + +def return_renamed_mock_class_instance() -> RenamedClass: + return RenamedClass() + + +def return_property_from_renamed_mock_class_instance() -> str: + return RenamedClass().property # type: ignore[no-any-return] # mypy doesn't like the custom property :shrug: + + +class MockSubClass(MockClass): + ... diff --git a/coveo-ref/tests_coveo_ref/mock_module/inner.py b/coveo-ref/tests_coveo_ref/mock_module/inner.py new file mode 100644 index 00000000..209546f8 --- /dev/null +++ b/coveo-ref/tests_coveo_ref/mock_module/inner.py @@ -0,0 +1,51 @@ +def inner_function() -> str: + return "Un pangolin ça marche comme M.Burns." + + +def inner_function_wrapper() -> str: + return inner_function() + + +class MockClass: + class NestedClass: + class DoubleNestedClass: + @property + def property(self) -> str: + return "Genre que leur pattes avant sont trop occuppé à dire 'excellent'." + + def instance_function(self) -> str: + return "Sont vraiment cute!" + + def instance_function(self) -> str: + return "Faudrait tu puisses voir la vidéo sur reddit." + + @property + def property(self) -> str: + return "Ouain." + + @classmethod + def classmethod(cls) -> str: + return "Tu comprendrais mieux." + + @staticmethod + def staticmethod() -> str: + return "L'histoire est pas mal finie!" + + +def _hidden_getter(_self: "MockClassToRename") -> str: + return "À prochaine!" + + +class MockClassToRename: + def instance_function(self) -> str: + return "Bon àprochainaaaaage!" + + def hidden_property_setter(self, value: str) -> None: + ... + + # this is the custom form of the @property decorator + property = property(_hidden_getter, hidden_property_setter) + + +def inner_mock_class_factory() -> MockClass: + return MockClass() diff --git a/coveo-ref/tests_coveo_ref/mock_module/shadow_rename.py b/coveo-ref/tests_coveo_ref/mock_module/shadow_rename.py new file mode 100644 index 00000000..0a4c38c5 --- /dev/null +++ b/coveo-ref/tests_coveo_ref/mock_module/shadow_rename.py @@ -0,0 +1,5 @@ +from tests_coveo_ref.mock_module.inner import inner_function as renamed_else_shadowed + + +def inner_function() -> None: + """Before the fix, we would use anything that matched the name without looking if it was the right thing.""" diff --git a/coveo-ref/tests_coveo_ref/py.typed b/coveo-ref/tests_coveo_ref/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/coveo-ref/tests_coveo_ref/test_mocks.py b/coveo-ref/tests_coveo_ref/test_mocks.py new file mode 100644 index 00000000..581210cc --- /dev/null +++ b/coveo-ref/tests_coveo_ref/test_mocks.py @@ -0,0 +1,341 @@ +from typing import Any, Callable, Final, Optional, Tuple, Type +from unittest import mock +from unittest.mock import PropertyMock, Mock, MagicMock + +import pytest +from coveo_ref import _PythonReference, ref +from coveo_ref.exceptions import UsageError +from coveo_testing.parametrize import parametrize +from tests_coveo_ref.mock_module import MockClass as TransitiveMockClass +from tests_coveo_ref.mock_module import ( + MockSubClass, + RenamedClass, + call_inner_function_from_another_module, + call_inner_function_wrapper_from_another_module, + return_property_from_renamed_mock_class_instance, + return_renamed_mock_class_instance, +) +from tests_coveo_ref.mock_module.inner import ( + MockClass, + MockClassToRename, + inner_function, + inner_function_wrapper, + inner_mock_class_factory, +) +from tests_coveo_ref.mock_module import shadow_rename + + +MOCKED: Final[str] = "mocked" + + +@parametrize( + ("target", "expected"), + _TEST_CASES := ( + ( + inner_function, + ((_INNER_MODULE := "tests_coveo_ref.mock_module.inner"), "inner_function", None), + ), + ( + MockClass.instance_function, + (_INNER_MODULE, "MockClass", "instance_function"), + ), + (MockClass.classmethod, (_INNER_MODULE, "MockClass", "classmethod")), + (MockClass.staticmethod, (_INNER_MODULE, "MockClass", "staticmethod")), + (MockClass.property, (_INNER_MODULE, "MockClass", "property")), + (MockClass, (_INNER_MODULE, "MockClass", None)), + # this is equivalent to the previous test: the original module always prevails. + (TransitiveMockClass, (_INNER_MODULE, "MockClass", None)), + ( + MockClass.NestedClass.DoubleNestedClass.instance_function, + ( + _INNER_MODULE, + "MockClass", + "NestedClass.DoubleNestedClass.instance_function", + ), + ), + # modules as string works too + (__name__, ("tests_coveo_ref.test_mocks", None, None)), + ), +) +def test_python_reference(target: Any, expected: Tuple[str, Optional[str], Optional[str]]) -> None: + """The _PythonReference object references the original module.""" + assert (reference := _PythonReference.from_any(target)) == _PythonReference(*expected) + _ = reference.import_symbol() + + +@parametrize(("target", "expected"), _TEST_CASES) +def test_ref(target: Any, expected: Tuple[str, Optional[str], Optional[str]]) -> None: + """`ref` without a context is similar to _PythonReference.""" + assert ref(target) == (".".join(filter(bool, expected)),) + + +@parametrize( + ("target", "context", "expected"), + ( + # with a context, we automatically find the correct object + ( + inner_function, + inner_function_wrapper, + "tests_coveo_ref.mock_module.inner.inner_function", + ), + ( + inner_function, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.inner_function", + ), + ( + inner_function, + call_inner_function_wrapper_from_another_module, + "tests_coveo_ref.mock_module.inner_function", + ), + ( + MockClass.instance_function, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.MockClass.instance_function", + ), + ( + MockClass.property, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.MockClass.property", + ), + ( + MockClassToRename.instance_function, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.RenamedClass.instance_function", + ), + ( + MockClassToRename, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.RenamedClass", + ), + ( + # this tests the long-shot discovery of properties, with a getter we can't use and a hidden setter. + MockClassToRename.property, + call_inner_function_from_another_module, + "tests_coveo_ref.mock_module.RenamedClass.property", + ), + # the 2 following cases test an edge case with renames + ( + shadow_rename.inner_function, + shadow_rename, + "tests_coveo_ref.mock_module.shadow_rename.inner_function", + ), + ( + inner_function, + shadow_rename, + "tests_coveo_ref.mock_module.shadow_rename.renamed_else_shadowed", + ), + ), +) +def test_ref_context(target: Any, context: Any, expected: str) -> None: + """ + `ref` with a context will fish for the symbol in context's module. + This covers the cases where the symbol is renamed during the import. + """ + assert ref(target, context=context) == (expected,) + + +@parametrize( + ("to_patch", "check"), + ( + (inner_function, inner_function_wrapper), + # With a wrapper, you don't have to think about the context. + (inner_function, call_inner_function_wrapper_from_another_module), + (MockClass, inner_mock_class_factory), + ), +) +def test_ref_symbol_called_from_wrapper(to_patch: Any, check: Callable[[], Any]) -> None: + """ + Wrapping a symbol behind another callable in the same module is a clever way to make a mock work from everywhere, + but requires changes to the source code. + """ + with mock.patch(*ref(to_patch), return_value=MOCKED) as mocked_fn: + assert check() == MOCKED + mocked_fn.assert_called_once() + + +@parametrize( + ("to_patch", "check"), + ( + (inner_function, call_inner_function_from_another_module), + (RenamedClass, return_renamed_mock_class_instance), + # in reality, RenamedClass is MockClassToRename; it's the context that is important here. + # same test again, but without specifying "RenamedClass". + (MockClassToRename, return_renamed_mock_class_instance), + ), +) +def test_ref_function_different_module(to_patch: Any, check: Callable[[], Any]) -> None: + """In order to make a mock work for a different module, we use `context`.""" + with mock.patch(*ref(to_patch, context=check), return_value=MOCKED) as mocked_fn: + assert check() == MOCKED + mocked_fn.assert_called_once() + + +@parametrize( + ("to_patch", "check"), + ((MockClassToRename.property, return_property_from_renamed_mock_class_instance),), +) +def test_ref_property_different_module(to_patch: Any, check: Callable[[], Any]) -> None: + """Mock a property.""" + with mock.patch( + *ref(to_patch, context=check), new_callable=PropertyMock, return_value=MOCKED + ) as mocked_property: + assert check() == MOCKED + mocked_property.assert_called_once() + + +@parametrize( + ("to_patch", "calling_type"), + ( + (MockClass.instance_function, MockClass), + (MockClass.instance_function, MockSubClass), + (MockClass.instance_function, TransitiveMockClass), + (MockSubClass.instance_function, MockSubClass), + (TransitiveMockClass.instance_function, TransitiveMockClass), + # comical example because why not + ( + MockClass.NestedClass.DoubleNestedClass.instance_function, + TransitiveMockClass.NestedClass.DoubleNestedClass, + ), + ), +) +def test_ref_instance_functions(to_patch: Any, calling_type: Type[MockClass]) -> None: + """Mocking a function on a class is trivial, and works across modules.""" + with mock.patch(*ref(to_patch), return_value=MOCKED) as mocked_fn: + assert calling_type().instance_function() == MOCKED + mocked_fn.assert_called_once() + + +@parametrize( + ("to_patch", "calling_type"), + ( + (MockClass.property, MockClass), + (MockClass.property, MockSubClass), + (MockClass.property, TransitiveMockClass), + (MockSubClass.property, MockSubClass), + (TransitiveMockClass.property, TransitiveMockClass), + # comical example because why not + ( + MockClass.NestedClass.DoubleNestedClass.property, + TransitiveMockClass.NestedClass.DoubleNestedClass, + ), + ), +) +def test_ref_properties(to_patch: Any, calling_type: Type[MockClass]) -> None: + """Mocking a property on a class is trivial, and works across modules.""" + with mock.patch( + *ref(to_patch), new_callable=PropertyMock, return_value=MOCKED + ) as mocked_property: + assert calling_type().property == MOCKED + mocked_property.assert_called_once() + + +@parametrize("context", (__name__, test_python_reference)) +def test_ref_class(context: Any) -> None: + """ + You can patch classes directly, in order to return whatever. But the module becomes important + again, and behaves exactly like functions at the module level. + """ + with mock.patch(*ref(MockClass, context=context), return_value=1) as mocked_class: + assert MockClass() == 1 + mocked_class.assert_called_once() + + +def test_ref_with_mock_patch_object() -> None: + """ + In order to unpack into `mock.patch.object`, we use `obj=True`. + It will only affect the instance passed in the target. + """ + instance = MockClass() + with mock.patch.object( + *ref(instance.instance_function, obj=True), return_value=MOCKED + ) as mocked_fn: + assert instance.instance_function() == MOCKED + mocked_fn.assert_called_once() + # new instances are not impacted, of course + assert MockClass().instance_function() != MOCKED + + +@pytest.mark.parametrize("method", ("classmethod", "staticmethod")) +@pytest.mark.parametrize("as_static", ("classmethod", "staticmethod")) +def test_ref_with_mock_patch_object_classmethod(method: str, as_static: bool) -> None: + """ + Some definitions, such as classmethods and staticmethods, are not attached to an instance. As a result, inspecting + them yield no way to retrieve the instance like normal functions do. + + But the mock module do allow patching them on an instance-basis using `patch.object()`. In order to keep things + refactorable, the user needs to provide the instance separately, as the context. + """ + instance = MockClass() + + # when specifying the context when `obj=True`, the target may be the static reference (on the class definition) + # rather than using the instance directly because it's the same object behind the scenes. + to_patch = getattr((MockClass if as_static else instance), method) + with mock.patch.object( + *ref(to_patch, context=instance, obj=True), return_value=MOCKED + ) as mocked_fn: + assert getattr(instance, method)() == MOCKED + mocked_fn.assert_called_once() + # new instances are not impacted, of course + assert getattr(MockClass(), method)() != MOCKED + + +@pytest.mark.skip(reason="Annotation test only.") +def test_ref_overloads() -> None: + """This makes sure that the typing / overloads work for mypy.""" + + def tuple_one_string(arg: Tuple[str]) -> None: + ... + + def tuple_two_strings(arg: Tuple[str, str]) -> None: + ... + + # noinspection PyUnreachableCode + # these are the correct usages + tuple_one_string(ref("target")) + tuple_one_string(ref("target")) + tuple_one_string(ref("target", context="context")) + tuple_two_strings(ref("target", obj=True)) + + # these are incorrect + tuple_one_string(ref("target", obj=True)) # type: ignore[arg-type] + tuple_two_strings(ref("target", context="context")) # type: ignore[arg-type] + tuple_two_strings(ref("target")) # type: ignore[arg-type] + + +def test_ref_cannot_resolve_mocks() -> None: + with pytest.raises(UsageError, match="Mocks cannot"): + ref(Mock()) + + with pytest.raises(UsageError, match="Mocks cannot"): + ref(MagicMock()) + + with pytest.raises(UsageError, match="Mocks cannot"): + ref(MockClass.instance_function, context=MagicMock()) + + with mock.patch(*ref(MockClass.instance_function)): + with pytest.raises(UsageError, match="Mocks cannot"): + ref(MockClass.instance_function) + + +@parametrize("thing", (MockClass(), MockClass, inner_function)) +def test_ref_cannot_obj_without_attributes(thing: Any) -> None: + """Check the exception raised when trying to patch an obj without an attribute.""" + with pytest.raises(UsageError, match="at least one attribute"): + ref(thing, obj=True) + + +@parametrize( + "thing", + ( + MockClass.instance_function, + MockClass.staticmethod, + MockClass().staticmethod, + MockClass.classmethod, + MockClass().classmethod, + ), +) +def test_ref_obj_is_global(thing: Any) -> None: + """Check the exception raised when trying to patch an obj that would probably turn out to be global.""" + with pytest.raises(UsageError, match="Cannot resolve an instance for the context"): + ref(thing, obj=True) diff --git a/pyproject.toml b/pyproject.toml index 00774dcd..8e2a4e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ coveo-example-library = { path = 'coveo-example-library/', develop = true } coveo-functools = { path = 'coveo-functools/', develop = true } coveo-itertools = { path = 'coveo-itertools/', develop = true } coveo-pypi-cli = { path = 'coveo-pypi-cli/', develop = true } +coveo-ref = { path = 'coveo-ref/', develop = true } coveo-settings = { path = 'coveo-settings/', develop = true } coveo-styles = { path = 'coveo-styles/', develop = true } coveo-systools = { path = 'coveo-systools/', develop = true } From b0bbea7fbff6a195d9bcfdeb86797c86a9017888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Pich=C3=A9?= Date: Fri, 27 Oct 2023 08:49:23 -0400 Subject: [PATCH 2/3] fix workflow --- .github/workflows/coveo-ref.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coveo-ref.yml b/.github/workflows/coveo-ref.yml index e9e008e2..57ea8f31 100644 --- a/.github/workflows/coveo-ref.yml +++ b/.github/workflows/coveo-ref.yml @@ -1,4 +1,4 @@ -name: coveo-testing +name: coveo-ref on: push: @@ -11,6 +11,7 @@ on: types: [opened, synchronize, reopened] paths: - 'coveo-ref/**' + - '.github/workflows/coveo-ref.yml' workflow_dispatch: inputs: @@ -35,7 +36,7 @@ jobs: - name: Run stew ci uses: coveo/stew@main with: - project-name: coveo-ref + project-name: ${{ github.workflow }} python-version: ${{ matrix.python-version }} poetry-version: "<2" @@ -51,12 +52,12 @@ jobs: - name: Setup python 3.10 uses: actions/setup-python@v4 with: - python-version: 3.10 + python-version: "3.10" - name: Publish to pypi uses: ./.github/workflows/actions/publish-to-pypi with: - project-name: coveo-ref + project-name: ${{ github.workflow }} pypi-token: ${{ secrets.PYPI_TOKEN_COVEO_REF }} pre-release: ${{ github.ref != 'refs/heads/main' }} dry-run: ${{ github.ref != 'refs/heads/main' && github.event.inputs.publish != 'true' }} From 2ba97177e5f589e3faf820b160d12feb7795dcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Pich=C3=A9?= Date: Fri, 27 Oct 2023 10:56:16 -0400 Subject: [PATCH 3/3] fix documentation --- coveo-ref/README.md | 20 ++++++++++---------- coveo-ref/coveo_ref/__init__.py | 2 +- coveo-ref/tests_coveo_ref/conftest.py | 8 -------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/coveo-ref/README.md b/coveo-ref/README.md index 944676f1..fd1f911a 100644 --- a/coveo-ref/README.md +++ b/coveo-ref/README.md @@ -19,7 +19,7 @@ tool that can extract the string representation of a python objet. This is what ```python from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import APIClient @patch(*ref(APIClient._do_request)) @@ -64,7 +64,7 @@ But with `ref`, you specify the context separately: ```python from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import get_api_client from mymodule.tasks import process @@ -100,7 +100,7 @@ The test: ```python from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import get_api_client from mymodule.tasks import process @@ -132,7 +132,7 @@ can unpack to `patch.object()`: ```python from unittest.mock import patch -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import APIClient def test() -> None: @@ -155,7 +155,7 @@ For instance, consider this test: ```python from http.client import HTTPResponse from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.tasks import process @@ -226,7 +226,7 @@ So you _know_ this works globally, because no one will (should?) import the priv ```python from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.tasks import process from mymodule.clients import _get_api_client @@ -259,7 +259,7 @@ The test, showing 3 different methods that work: ```python from unittest.mock import patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import get_api_client from mymodule.tasks import process @@ -296,7 +296,7 @@ Sometimes, the test file _is_ the context. When that happens, just pass `__name_ ```python from unittest.mock import patch -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule.clients import get_api_client, APIClient def _prepare_test() -> APIClient: @@ -377,7 +377,7 @@ class MyClass: ```python from unittest.mock import PropertyMock, patch, MagicMock -from coveo_testing.mocks import ref +from coveo_ref import ref from mymodule import MyClass @@ -406,7 +406,7 @@ For this particular scenario, the workaround is to provide the instance as the c ```python from unittest.mock import patch -from coveo_testing.mocks import ref +from coveo_ref import ref class MyClass: diff --git a/coveo-ref/coveo_ref/__init__.py b/coveo-ref/coveo_ref/__init__.py index 1aa5c130..07b4332a 100644 --- a/coveo-ref/coveo_ref/__init__.py +++ b/coveo-ref/coveo_ref/__init__.py @@ -329,7 +329,7 @@ def ref( refactorable mocks. The idea is to provide the thing to mock as the target, and sometimes, the thing that is being tested - as the context. Refer to `coveo-testing`'s readme to better understand when a context is necessary. + as the context. Refer to `coveo-ref`'s readme to better understand when a context is necessary. For example, pass the `HTTPResponse` class as the target and the `my_module.function_to_test` function as the context, so that `my_module.HTTPResponse` becomes mocked (and not httplib.client.HTTPResponse). diff --git a/coveo-ref/tests_coveo_ref/conftest.py b/coveo-ref/tests_coveo_ref/conftest.py index 5050167a..52a2ae1f 100644 --- a/coveo-ref/tests_coveo_ref/conftest.py +++ b/coveo-ref/tests_coveo_ref/conftest.py @@ -1,9 +1 @@ """pytest bootstrap""" - -from _pytest.config import Config -from coveo_testing.markers import register_markers - - -def pytest_configure(config: Config) -> None: - """This pytest hook is ran once, before collecting tests.""" - register_markers(config)