Skip to content
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

Handle parameterised generics #598

Merged
merged 7 commits into from
Aug 29, 2024
Merged

Handle parameterised generics #598

merged 7 commits into from
Aug 29, 2024

Conversation

DiamondJoseph
Copy link
Collaborator

Closes #597

Copy link

codecov bot commented Aug 20, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 92.99%. Comparing base (1d80837) to head (70108ea).
Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #598      +/-   ##
==========================================
+ Coverage   92.97%   92.99%   +0.01%     
==========================================
  Files          40       40              
  Lines        1823     1826       +3     
==========================================
+ Hits         1695     1698       +3     
  Misses        128      128              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@DiamondJoseph DiamondJoseph force-pushed the param_generic branch 2 times, most recently from c01b2e0 to 7a78f59 Compare August 20, 2024 13:08
@ZohebShaikh
Copy link
Collaborator

Nice @DiamondJoseph , I wanted to know if we can test the BlueAPI endpoints as part of this PR or should they be separate...It is so that we can have more confidence in our code that it is working

Copy link
Collaborator

@stan-dot stan-dot left a comment

Choose a reason for hiding this comment

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

no idea what is going on

Comment on lines 155 to 157
def is_valid_return(value: Any, expected_type: type[T]) -> bool:
if expected_type is Any:
return True
Copy link
Collaborator

Choose a reason for hiding this comment

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

it's not documented. when would we need this?

why do we use this 'rpc'?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We have 2 processes in blueapi: 1 exposes the REST API, and the other holds the RunEngine. The _rpc method in this file passes requests from the first to the second. As part of the validation and type hinting, the expected return type of the function is passed, just in case this has changed in the subprocess holding the RunEngine.

If the function that is to be run in the subprocess has a return annotation of Any, then whatever the value that is returned by running the function in the subprocess is, it is valid. If the return annotation is None, then the returned value is only valid if it is None.
The linked issue was exposed when returning a subscripted list, e.g. list[DeviceModel], as isinstance(foo, list[DeviceModel]) throws an exception. So this new code is a kind of isinstance that recursively finds the origin (list in the example) and args (DeviceModel in the example) and checks that the returned value is valid for both.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

can we just have no subprocesses?

#504

Copy link
Collaborator

Choose a reason for hiding this comment

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

This PR makes #504 easier by making the subprocess work more like a separate RPC service...

Copy link
Collaborator

@stan-dot stan-dot Aug 21, 2024

Choose a reason for hiding this comment

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

504 isn't difficult in the first place imo, and this feels like reinventing the wheel

Copy link
Collaborator

Choose a reason for hiding this comment

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

You may be right, but this is the smallest amount of work required to unblock people here and now

@joeshannon
Copy link
Contributor

Nice @DiamondJoseph , I wanted to know if we can test the BlueAPI endpoints as part of this PR or should they be separate...It is so that we can have more confidence in our code that it is working

The endpoints themselves have tests (test_rest_api.py) however these are unit tests and don't test the subprocess, or actually running a plan etc.

runner.py has its own unit tests which could possibly be enhanced to catch this.

When refactoring in this area it took me a very long time to fix up the tests as they were all mocking various things across many levels of indirection including private components. The tests are now closer to actual unit tests.

What blueapi needs now is probably some form of system tests to confirm end to end functionality and results.
I would probably suggest that this would be done as part of a different change.

@callumforrester
Copy link
Collaborator

@DiamondJoseph Have written and pushed some test cases, all but one pass

Comment on lines 155 to 182
def is_valid_return(value: Any, expected_type: type[T]) -> bool:
if expected_type is Any:
return True
if expected_type is type(None):
return value is None
origin = get_origin(expected_type)
if origin is None or origin is Union: # works for Python >= 3.10
return isinstance(value, expected_type)
args = get_args(expected_type)
if issubclass(origin, Mapping):
return isinstance(value, Mapping) and all(
is_valid_return(k, args[0]) and is_valid_return(v, args[1])
for k, v in value.items()
)
if origin is tuple: # handle ellipses specially
assert isinstance(value, tuple)
if args[1] is Ellipsis:
args = (args[0],) * len(value)
else:
return all(
is_valid_return(inner, arg)
for inner, arg in zip(value, args, strict=True)
)
if issubclass(origin, Collection): # list, set, etc.
return isinstance(value, Collection) and all(
is_valid_return(inner, args[0]) for inner in value
)
raise TypeError(f"Unknown origin for generic type {expected_type}")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could pydantic.TypeAdapter do all of this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If it can, I can't test until #449

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@callumforrester with Pydantic.TypeAdapter it's still got an issue with the same single case. My handling is equally as good ;)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Shall we go with TypeAdapter and not support that case then? I don't see a use case for it currently.

Copy link
Collaborator

@callumforrester callumforrester left a comment

Choose a reason for hiding this comment

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

LGTM!

f"{function_name} returned value of type {type(value)}"
+ f" which is incompatible with expected {expected_type}"
)
return TypeAdapter(expected_type).validate_python(value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Can this go in an else clause?

@DiamondJoseph
Copy link
Collaborator Author

I think pydantic/pydantic#6870 is the cause of our failing test case. In the debug console:

from typing import Generic, TypeVar
from pydantic import BaseModel, TypeAdapter

T = TypeVar("T")

class GenericModel(BaseModel, Generic[T]):
    t: T

g = GenericModel[int](t=1)
h = GenericModel(t=2)

ta = TypeAdapter(GenericModel)
ta2 = TypeAdapter(GenericModel[int])

ta.validate_python(g)
ta.validate_python(h)
ta2.validate_python(g)
# fails
ta2.validate_python(h)

As the return annotation of the function is ~GenericModel[int].

@DiamondJoseph DiamondJoseph merged commit ad3f686 into main Aug 29, 2024
24 checks passed
@DiamondJoseph DiamondJoseph deleted the param_generic branch August 29, 2024 12:27
ZohebShaikh pushed a commit that referenced this pull request Aug 29, 2024
Closes #597

---------

Co-authored-by: Callum Forrester <[email protected]>
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.

_rpc function cannot return generics
5 participants