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

(Partial) support for dynamically generated types #13643

Open
apirogov opened this issue Sep 9, 2022 · 4 comments
Open

(Partial) support for dynamically generated types #13643

apirogov opened this issue Sep 9, 2022 · 4 comments
Labels

Comments

@apirogov
Copy link

apirogov commented Sep 9, 2022

Feature

I don't know about implementation details of mypy, whether it uses an own parser for Python code or could work based on Python's introspection capabilities. However, I think the amount of stuff that mypy could "see" and work with could increase dramatically, if it would actually load a module (thereby running some code that possibly generates some annotations), and only THEN analyze resulting type hints.

Pitch

I am working on a project which heavily uses types (due to heavy use of pydantic), but at the same time is very dynamic.

Example 1:

Sometimes I need something on value level as well as on type level. Currently I use a pattern like that in the module, if I want both mypy to check types and also do some runtime checking to catch misuse:

OpenMode = Literal["r", "r+", "a", "w", "w-", "x"]
_OPEN_MODES = list(get_args(OpenMode))

But there are situations, where going the other way is more natural, i.e. "lift" a value into a type, instead of unpacking it.

Example 1b:

Given a function I wrote and use for dynamically adjusted pydantic models, make_literal,

# evaluates to a TypedDict with a: Literal["foo"] and b: Literal[123]
LiftedValue = make_literal(dict(a="foo", b=123))  

is much more convenient than doing the inverse (having to write out the TypedDict by hand, then unpacking it).

Having this "expansion" work for types defined on value level, so they can be validly understood in following code, would be great.
Of course I understand this is probably impossible to support in an actual annotation, because annotations cannot be assumed to be evaluated in general.

Example 2:

A part of the project "dynamicism" comes from the fact that it is centered around an entry-point based plugin system.

I would like to be able to load some entry points and adjust the __annotations__, so that mypy can pick them up. That would also require that I can tell mypy where the something is coming from, without importing it - because I could basically tell mypy for some entity where the source code lives!

So I would like to have something like that work:

ObjectType = type_from_entrypoint(ep)
my_object: ObjectType = my_fancy_plugin_loader(ep)

or actually:

for p_name, plugin in my_fancy_plugins.items():
  globals()[p_name] =plugin.p_cls
  if TYPE_CHECKING:
     __annotations__[p_name] = Annotated[plugin.p_cls, EntryPoint[plugin.ep]]

So I would simply like that mypy is able to treat classes loaded from entrypoints just like it can make sense of imports - the source code location for entrypoints is easily accessible too! And proper type information is simply one load of the module away.

I think restricting this kind of dynamically added type hinting to "things that automatically are evaluated on module load" would be a natural and good trade-off, because loading a module usually won't run arbitrarily complex or expensive computations, and at the same time it would be immensely powerful.

Now of course this would increase the "risk" of circular imports and affect type checking speed, but sometimes you have to work around that even without using type hints. But I see how this could be an opt-in feature and not something enabled by default.

Or, if this is totally unthinkable for mypy, does anyone know a Python type checker that can do something like that?

@JelleZijlstra
Copy link
Member

This is difficult to achieve within mypy's architecture.

I maintain a type checker called pyanalyze (https://github.com/quora/pyanalyze) that does import the modules it type checks, so it supports the patterns you need.

@sobolevn
Copy link
Member

Mypy actually does support exactly this:

ObjectType = type_from_entrypoint(ep)
my_object: ObjectType = my_fancy_plugin_loader(ep)

See get_dynamic_class_hook() in https://mypy.readthedocs.io/en/stable/extending_mypy.html

I don't think any extra features are planned to support this kind of dynamic code.

@apirogov
Copy link
Author

Thanks for both these hints!

@sobolevn is there some example plugin implementing this hook that I could use to get started?

Couldn't the same or another hook be used to teach mypy to understand some "type generating functions", like the helpers I outlined for lifting values into types?

And is get_dynamic_class_hook limited to a "syntactic" function call only (i.e. normal parentheses, __call__), or would using dict-like access (square brackets, __getitem__) also work?

My plugin system is exposed through dict-like objects so pluginGroup["pluginName"] would return a plugin of certain type with certain name that was loaded from an entrypoint. If a little mypy extension based on that hook could pass through the relevant info about the class to mypy, it would solve most of my problems with static type checking.

@sobolevn
Copy link
Member

@apirogov yes, here's an example: https://github.com/python/mypy/blob/5bd2641ab53d4261b78a5f8f09c8d1e71ed3f14a/test-data/unit/plugins/dyn_class_from_method.py

And is get_dynamic_class_hook limited to a "syntactic" function call only

Yes, only calls. We do not really care about which call it is, see:

mypy/mypy/semanal.py

Lines 2820 to 2847 in 216a45b

def apply_dynamic_class_hook(self, s: AssignmentStmt) -> None:
if not isinstance(s.rvalue, CallExpr):
return
fname = None
call = s.rvalue
while True:
if isinstance(call.callee, RefExpr):
fname = call.callee.fullname
# check if method call
if fname is None and isinstance(call.callee, MemberExpr):
callee_expr = call.callee.expr
if isinstance(callee_expr, RefExpr) and callee_expr.fullname:
method_name = call.callee.name
fname = callee_expr.fullname + "." + method_name
elif isinstance(callee_expr, CallExpr):
# check if chain call
call = callee_expr
continue
break
if not fname:
return
hook = self.plugin.get_dynamic_class_hook(fname)
if not hook:
return
for lval in s.lvalues:
if not isinstance(lval, NameExpr):
continue
hook(DynamicClassDefContext(call, lval.name, self))

You can rework your API to be something like plugin_group(data, "pluginName")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants