-
Notifications
You must be signed in to change notification settings - Fork 4
Add better static typing to Captor #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,12 +27,15 @@ def test_logger_called(decoy: Decoy): | |
| equality comparisons (`==`) for stubbing and verification. | ||
| """ | ||
|
|
||
| from abc import abstractmethod | ||
| from re import compile as compile_re | ||
| from typing import cast, Any, List, Mapping, Optional, Pattern, Type, TypeVar | ||
| from typing import cast, overload, Any, List, Mapping, Optional, Pattern, Protocol, Type, TypeVar | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "Anything", | ||
| "AnythingOrNone", | ||
| "ArgumentCaptor", | ||
| "Captor", | ||
| "ErrorMatching", | ||
| "IsA", | ||
|
|
@@ -362,50 +365,100 @@ def ErrorMatching(error: Type[ErrorT], match: Optional[str] = None) -> ErrorT: | |
| return cast(ErrorT, _ErrorMatching(error, match)) | ||
|
|
||
|
|
||
| class _Captor: | ||
| def __init__(self) -> None: | ||
| self._values: List[Any] = [] | ||
| CapturedT = TypeVar("CapturedT") | ||
|
|
||
| def __eq__(self, target: object) -> bool: | ||
| """Capture compared value, always returning True.""" | ||
| self._values.append(target) | ||
| return True | ||
|
|
||
| def __repr__(self) -> str: | ||
| """Return a string representation of the matcher.""" | ||
| return "<Captor>" | ||
| class ArgumentCaptor(Protocol[CapturedT]): | ||
| """Captures method arguments for later assertions. | ||
|
|
||
| Use the `capture()` method to pass the captor as an argument when verifying a method. | ||
| The last captured argument is available via `captor.value`, and all captured arguments | ||
| are stored in `captor.values`. | ||
|
|
||
| !!! example | ||
| ```python | ||
| captor: ArgumentCaptor[str] = Captor(match_type=str) | ||
| assert "foobar" == captor.capture() | ||
| assert 2 != captor.capture() | ||
| print(captor.value) # "foobar" | ||
| print(captor.values) # ["foobar"] | ||
| ``` | ||
| """ | ||
| def capture(self) -> CapturedT: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: I like this idea of a generic class with a type-cast property to pass to the stub configuration. I think it might be hard to land such a change in Decoy v2, but I think it could be a cool API to implement for all matchers in Decoy v3, if needed. That way, there could be a public, generic |
||
| """Match anything, capturing its value. | ||
|
|
||
| !!! note | ||
| This method exists solely to match the target argument type and suppress type checker warnings. | ||
| """ | ||
| return cast(CapturedT, self) | ||
|
|
||
| @property | ||
| def value(self) -> Any: | ||
| @abstractmethod | ||
| def value(self) -> CapturedT: | ||
| """Get the captured value. | ||
|
|
||
| Raises: | ||
| AssertionError: if no value was captured. | ||
| """ | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def values(self) -> List[CapturedT]: | ||
| """Get all captured values.""" | ||
|
|
||
|
|
||
| class _Captor(ArgumentCaptor[CapturedT]): | ||
| _values: List[CapturedT] | ||
| _match_type: Type[CapturedT] | ||
|
|
||
| def __init__(self, match_type: Type[CapturedT]) -> None: | ||
| self._values = [] | ||
| self._match_type = match_type | ||
|
|
||
| def __eq__(self, target: object) -> bool: | ||
| if isinstance(target, self._match_type): | ||
| self._values.append(target) | ||
| return True | ||
| return False | ||
|
|
||
| def __repr__(self) -> str: | ||
| """Return a string representation of the matcher.""" | ||
| return "<Captor>" | ||
|
|
||
| @property | ||
| def value(self) -> CapturedT: | ||
| if len(self._values) == 0: | ||
| raise AssertionError("No value captured by captor.") | ||
|
|
||
| return self._values[-1] | ||
|
|
||
| @property | ||
| def values(self) -> List[Any]: | ||
| """Get all captured values.""" | ||
| def values(self) -> List[CapturedT]: | ||
| return self._values | ||
|
|
||
|
|
||
| def Captor() -> Any: | ||
| """Match anything, capturing its value. | ||
| MatchT = TypeVar("MatchT") | ||
|
|
||
|
|
||
| @overload | ||
| def Captor() -> Any: ... | ||
| @overload | ||
| def Captor(match_type: Type[MatchT]) -> ArgumentCaptor[MatchT]: ... | ||
| def Captor(match_type: Type[object] = object) -> Any: | ||
| """Match anything, capturing its value for further assertions. | ||
|
|
||
| The last captured value will be set to `captor.value`. All captured | ||
| values will be placed in the `captor.values` list, which can be | ||
| helpful if a captor needs to be triggered multiple times. | ||
|
|
||
| Arguments: | ||
| match_type: Optional type to match. | ||
|
|
||
| !!! example | ||
| ```python | ||
| captor = Captor() | ||
| assert "foobar" == captor | ||
| assert "foobar" == captor.capture() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the |
||
| print(captor.value) # "foobar" | ||
| print(captor.values) # ["foobar"] | ||
| ``` | ||
| """ | ||
| return _Captor() | ||
| return _Captor(match_type) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ Decoy includes the [decoy.matchers][] module, which is a set of Python classes w | |
| | Matcher | Description | | ||
| | --------------------------------- | ---------------------------------------------------- | | ||
| | [decoy.matchers.Anything][] | Matches any value that isn't `None` | | ||
| | [decoy.matchers.AnythingOrNone][] | Matches any value including `None` | | ||
| | [decoy.matchers.DictMatching][] | Matches a `dict` based on some of its values | | ||
| | [decoy.matchers.ErrorMatching][] | Matches an `Exception` based on its type and message | | ||
| | [decoy.matchers.HasAttributes][] | Matches an object based on its attributes | | ||
|
|
@@ -67,7 +68,7 @@ def test_event_listener(decoy: Decoy): | |
| subject.start_consuming() | ||
|
|
||
| # verify listener attached and capture the listener | ||
| decoy.verify(event_source.register(event_listener=captor)) | ||
| decoy.verify(event_source.register(event_listener=captor.capture())) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as before, I propose to be consistent in the docs and always use the |
||
|
|
||
| # trigger the listener | ||
| event_handler = captor.value # or, equivalently, captor.values[0] | ||
|
|
@@ -81,6 +82,8 @@ This is a pretty verbose way of writing a test, so in general, you may want to a | |
|
|
||
| For further reading on when (or rather, when not) to use argument captors, check out [testdouble's documentation on its argument captor matcher](https://github.com/testdouble/testdouble.js/blob/main/docs/6-verifying-invocations.md#tdmatcherscaptor). | ||
|
|
||
| If you want to only capture values of a specific type, or you would like to have stricter type checking in your tests, consider passing a type to [decoy.matchers.Captor][] (e.g. `Captor(match_type=str)`). | ||
|
|
||
| ## Writing custom matchers | ||
|
|
||
| You can write your own matcher class and use it wherever you would use a built-in matcher. All you need to do is define a class with an `__eq__` method: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -118,15 +118,37 @@ | |
| from decoy import matchers | ||
| reveal_type(matchers.Anything()) | ||
| reveal_type(matchers.AnythingOrNone()) | ||
| reveal_type(matchers.IsA(str)) | ||
| reveal_type(matchers.IsNot(str)) | ||
| reveal_type(matchers.HasAttributes({"foo": "bar"})) | ||
| reveal_type(matchers.DictMatching({"foo": 1})) | ||
| reveal_type(matchers.ListMatching([1])) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added for completeness |
||
| reveal_type(matchers.StringMatching("foobar")) | ||
| reveal_type(matchers.ErrorMatching(RuntimeError)) | ||
| reveal_type(matchers.Captor()) | ||
| out: | | ||
| main:3: note: Revealed type is "Any" | ||
| main:4: note: Revealed type is "Any" | ||
| main:5: note: Revealed type is "Any" | ||
| main:6: note: Revealed type is "builtins.str" | ||
| main:7: note: Revealed type is "builtins.RuntimeError" | ||
| main:6: note: Revealed type is "Any" | ||
| main:7: note: Revealed type is "Any" | ||
| main:8: note: Revealed type is "Any" | ||
| main:9: note: Revealed type is "Any" | ||
| main:10: note: Revealed type is "builtins.str" | ||
| main:11: note: Revealed type is "builtins.RuntimeError" | ||
| main:12: note: Revealed type is "Any" | ||
| - case: captor_mimics_types | ||
| main: | | ||
| from decoy import matchers | ||
| captor = matchers.Captor(str) | ||
| reveal_type(captor) | ||
| reveal_type(captor.capture()) | ||
| reveal_type(captor.values) | ||
| out: | | ||
| main:5: note: Revealed type is "decoy.matchers.ArgumentCaptor[builtins.str]" | ||
| main:6: note: Revealed type is "builtins.str" | ||
| main:7: note: Revealed type is "builtins.list[builtins.str]" | ||
Uh oh!
There was an error while loading. Please reload this page.