Skip to content

Conversation

Alejandro-FA
Copy link

@Alejandro-FA Alejandro-FA commented Jul 6, 2025

Summary

Improve static typing support for argument matchers the argument captor.

Note: Feel free to suggest changes or deny the PR. I'm open for discussions.

Motivation and context

One of the selling points of Decoy, at least for me, is its well-typed interface, which improves the developer experience. However, the typing of argument matchers falls a bit short in this regard. The previous implementation relied heavily on using Any as a return type, but it can be improved with the use of Generics.

Description of changes

  1. Change the Captor matcher to provide a typed interface for getting the captured values, instead of having to get them from an object of type Any. By separating the object used to get the captured values and the object passed as a matcher, we get a typed interface with better support for for auto-completions in the IDE. I've also added an optional parameter to specify the type to match.
from decoy import Decoy, matchers

class Dependency():
    def do_thing(self, input: str) -> int:
        return 42

decoy = Decoy()
fake = decoy.mock(cls=Dependency)

captor: ArgumentCaptor[Any] = matchers.argument_captor()
captor_typed: ArgumentCaptor[str] = matchers.argument_captor(str)

decoy.when(fake.do_thing(captor.capture())).then_return(42) # captor.capture() has type Any
decoy.when(fake.do_thing(captor_typed.capture())).then_return(42) # captor.capture() has type str

values: list[Any] = captor.values
values_typed: list[str] = captor_typed.values
  1. On the other hand, I've changed the return type of matchers to use a generic type. For matchers without a clear expected type (like Anything or HasAttributes), I use a single generic type variable as the return type.
  • For pyright (which supports bidirectional inference), this means that the matcher infers the target type based on the context. See an example here. This approach is used by Mockito in Java.

  • For mypy, which is not capable of this type of inference (see an example here), the generic type is assigned its default value, which is Any for backwards compatibility.

This change helps to remove errors and warnings of strict type checkers that report any usage of Any, like basedpyright.

Testing

I have added both unit tests and type tests to verify the new expected behavior. I have also fixed a type test that was failing because of a syntax error.

Documentation

I have updated the documentation with the new functionality. I have also fixed a couple of errors that I found.

Copy link
Owner

@mcous mcous left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this contribution! For future reference, I think this sort of change should've started off with an issue to have a discussion rather than jumping right in with a PR. I also won't have time to properly review this for at least a week because I'm on vacation!

I'll try to keep an eye on this PR to ensure CI runs on any changes you push, but you likely won't hear much else from me for a bit. In the meantime, I've got some requests and suggestions if you're interested in taking this further:

  • I'd like to be pretty conservative with changes to the matchers, because there's a high risk of inadvertent breaking changes. I'd recommend scoping this PR down to solely trying to add a strict typing option to Captor
  • Deprecation warnings are pretty disruptive, too. I'd like to avoid deprecating any APIs as part of this effort
  • For now, Decoy supports Python versions all the way back to 3.7, so one must be careful to avoid using typing syntax and features that are too recent

Otherwise, keep an eye on CI!

I'm actively working on Decoy v3 which will have the ability to break some APIs, so I appreciate that this PR can provide some inspiration to that effort, even if some changes might not be able to land right away. Feel free to leave any thoughts or ideas in the discussion.

For what it's worth, I'm considering dropping matchers from Decoy in the v3 release in favor of hamcrest. I know their matchers offer some form of strict typing, and they may already work with Decoy, so that might be something interesting for you to explore if the built-in matchers don't offer typing to your liking.

I'm also not sure if captors will be necessary in Decoy v3. It may prove more straightforward (and more strictly typed) to provide an API that returns the list of all calls to a mock

@overload
def Captor() -> Any: ...
@overload
def Captor(match_type: type[MatchT]) -> MatchT: ...
Copy link
Owner

@mcous mcous Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I wonder if this overload provides a way to add the idea of a typed captor in a less disruptive way, e.g.:

@overload
def Captor(match_type: Type[MatchT]) -> TypedCaptor[MatchT]: ...


...


class TypedCaptor(NamedTuple, Generic[MatchT]):
    captor: MatchT
    values: CaptorValues[MatchT]
  

class CaptorValues(Protocol, Generic[MatchT]):
    @property
    def value(self) -> MatchT: ...

    @property
    def values(self) -> List[MatchT]: ...

Usage:

name_captor, name_values = Captor(str)

decoy.verify(greet(name_captor))

greet("Alice")

assert name_values.value == "Alice"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea, thanks! Following this approach, I got rid of the unnecessary argument_captor function that was used to create _Captor instances. It can all be done with the already present Captor function.

However, I believe that using the proposed capture method:

    def capture(self) -> CapturedT:
        return cast(CapturedT, self)

is still a better approach than having separate TypedCaptor and CaptorValues. Both kind of require an additional step with respect to the untyped Captor, but the capture method might be more familiar for users coming from other mocking libraries.

]

for compare in comparisons:
if isinstance(compare, int):
Copy link
Owner

@mcous mcous Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I have a stance that if conditions should be disallowed inside tests. Instead of an if inside one test function, two (or more) test functions should be used

That being said, I can't really figure out why this if is here. What is it doing?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your feedback! I agree that using if conditions inside tests is not ideal. I have refactored the test to avoid the use of if conditions and better reflect the goal of the test. However, if you feel that it would still be better to split it into two, let me know.

The isinstance(compare, int) statement is used to verify that only arguments of type int are captured.

This is a pretty verbose way of writing a test, so in general, you may want to approach using `matchers.Captor` as a form of potential code smell / test pain. There are often better ways to structure your code for these sorts of interactions that don't involve private functions.
!!! tip

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.argument_captor][] (e.g. `argument_captor(match_type=str)`).
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this language implies that the value will be type-checked at runtime, which is not true (nor do I think it should be true!). Should this be phrased to make it more clear that this argument merely casts the value type?

I also think the admonition is a little unnecessary. I think this can be regular text

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The language implies that the value will be type-checked at runtime because that's my intended behavior. Originally, I also believed that this should not be the case. However, I believe that the following example could become confusing then:

captor = matchers.Captor(match_type=str)
decoy.verify(do_something(captor.capture()))

do_something("Alice")
do_something(1)

assert captor.values == ["Alice", 1] # Isn't a bit confusing to also capture 1 when match_type=str?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the admonition is not necessary.

"""

@abstractmethod
def capture(self) -> CapturedT:
Copy link
Owner

Choose a reason for hiding this comment

The 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 Matcher class, and we could drop a lot of the complexity/silliness around the existing marchers API

@Alejandro-FA Alejandro-FA force-pushed the make_matchers_return_expected_type branch 2 times, most recently from 3e92daa to 12b0fbe Compare August 21, 2025 07:47
@Alejandro-FA Alejandro-FA force-pushed the make_matchers_return_expected_type branch from 12b0fbe to cb38433 Compare August 21, 2025 07:50
@Alejandro-FA
Copy link
Author

Hey, thanks a lot for your feedback and for taking such a good look at this PR. Given your comments, I agree that the changes to matchers introduce many potential breaking changes, so I will reduce the scope of the PR to the argument captor.

Sorry for not responding earlier, I hadn't had the time to review your feedback properly.

Also, change ABC with a Protocol, and adapt tests to the changes.
```python
captor = Captor()
assert "foobar" == captor
assert "foobar" == captor.capture()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the capture method is not mandatory, and assert "foobar" == captor still works exactly the same. However, I believe that it would be beneficial to be consistent in the docs and always use the capture method. This way, users don't need to change how do they use the captor when specifying a match_type.


# verify listener attached and capture the listener
decoy.verify(event_source.register(event_listener=captor))
decoy.verify(event_source.register(event_listener=captor.capture()))
Copy link
Author

Choose a reason for hiding this comment

The 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 capture method.

reveal_type(matchers.IsNot(str))
reveal_type(matchers.HasAttributes({"foo": "bar"}))
reveal_type(matchers.DictMatching({"foo": 1}))
reveal_type(matchers.ListMatching([1]))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added for completeness

@Alejandro-FA Alejandro-FA changed the title Add better static typing to matchers Add better static typing to Captor Aug 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants