-
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?
Add better static typing to Captor #270
Conversation
There was a problem hiding this 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
decoy/matchers.py
Outdated
@overload | ||
def Captor() -> Any: ... | ||
@overload | ||
def Captor(match_type: type[MatchT]) -> MatchT: ... |
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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.
tests/test_matchers.py
Outdated
] | ||
|
||
for compare in comparisons: | ||
if isinstance(compare, int): |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
docs/usage/matchers.md
Outdated
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)`). |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
3e92daa
to
12b0fbe
Compare
12b0fbe
to
cb38433
Compare
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() |
There was a problem hiding this comment.
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())) |
There was a problem hiding this comment.
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])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added for completeness
Summary
Improve static typing support for
argument matchersthe argument captor.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
Captor
matcher to provide a typed interface for getting the captured values, instead of having to get them from an object of typeAny
. 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.Anything
orHasAttributes
), 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.