Sorry it took me 3+ days to reply--I had a lot to think about here.  But I have good things to report!


On 1/11/21 8:42 PM, Guido van Rossum wrote:
On Mon, Jan 11, 2021 at 1:20 PM Larry Hastings <la...@hastings.org <mailto:la...@hastings.org>> wrote:

    PEP 563 states:

        For code that uses type hints, the typing.get_type_hints(obj,
        globalns=None, localns=None) function correctly evaluates
        expressions back from its string form.

    So, if you are passing in a localns argument that isn't None,
    okay, but you're not using them "correctly" according to the
    language.  Also, this usage won't be compatible with static type
    checkers.

I think you're misreading PEP 563 here. The mention of globalns=None, localns=None refers to the fact that these parameters have defaults, not that you must pass None. Note that the next paragraph in that PEP mentions eval(ann, globals, locals) -- it doesn't say eval(ann, {}, {}).

I think that's misleading, then.  The passage is telling you how to "correctly evaluate[s] expressions", and how I read it was, it's telling me I have to supply globalns=None and localns=None for it to work correctly--which, I had to discover on my own, were the default values.  I don't understand why PEP 563 feels compelled to define a function that it's not introducing, and in fact had already shipped with Python two versions ago.


Later in that same section, PEP 563 points out a problem with annotations that reference class-scoped variables, and claims that the implementation would run into problems because methods can't "see" the class scope. This is indeed a problem for PEP 563, but *you* can easily generate correct code, assuming the containing class exists in the global scope (and your solution requires that anyway). So in this case
```
class Outer:
    class Inner:
       ...
    def method(self, a: Inner, b: Outer) -> None:
        ...
```
The generated code for the `__annotations__` property could just have a reference to `Outer.Inner` for such cases:
```
def __annotations__():
    return {"a": Outer.Inner, "b": Outer, "return": None}
```

This suggestion was a revelation for me.  Previously, a combination of bad experiences early on when hacking on compile and symtable, and my misunderstanding of exactly what was being asserted in the November 2017 thread, led me to believe that all I could support was globals.  But I've been turning this over in my head for several days now, and I suspect I can support... just about anything.


I can name five name resolution scenarios I might encounter. I'll discuss them below, in increasing order of difficulty.


*First* is references to globals / builtins.  That's already working, it's obvious how it works, and I need not elaborate further.


*Second* is local variables in an enclosing function scope:

   def outer_fn():
        class C: pass
        def inner_fn(a:C=None): pass
        return inner_fn

As you pointed out elsewhere in un-quoted text, I could make the annotation a closure, so it could retain a reference to the value of (what is from its perspective) the free variable "C".


*Third* is local variables in an enclosing class scope, as you describe above:

   class OuterCls:
        class InnerCls:
            def method(a:InnerCls=None): pass

If I understand what you're suggesting, I could notice inside the compiler that Inner is being defined in a class scope, walk up the enclosing scopes until I hit the outermost class, then reconstruct the chain of pulling out attributes until it resolves globally. Thus I'd rewrite this example to:

   class OuterCls:
        class InnerCls:
            def method(a:OuterCls.InnerCls=None): pass

We've turned the local reference into a global reference, and we already know globals work fine.


*Fourth* is local variables in an enclosing class scope, which are themselves local variables in an enclosing function scope:

   def outerfn():
        class OuterCls:
            class InnerCls:
                def method(a:InnerCls=None): pass
        return OuterCls.InnerCls

Even this is solvable, I just need to combine the "second" and "third" approaches above.  I walk up the enclosing scopes to find the outermost class scope, and if that's a function scope, I create a closure and retain a reference to /that/ free variable.  Thus this would turn into

   def outerfn():
        class OuterCls:
            class InnerCls:
                def method(a:OuterCls.InnerCls=None): pass

and method.__co_annotations__ would reference the free variable "OuterCls" defined in outerfn.


*Fifth* is the nasty one.  Note that so far every definition we've referred to in an annotation has been /before/ the definition of the annotation.  What if we want to refer to something defined /after/ the annotation?

   def outerfn():
        class OuterCls:
            class InnerCls:
                def method(a:zebra=None): pass
                ...

We haven't seen the definition of "zebra" yet, so we don't know what approach to take.  It could be any of the previous four scenarios.  What do we do?

This is solvable too: we simply delay the compilation of __co_annotations__ code objects until the very last possible moment.  First, at the time we bind the class or function, we generate a stub __co_annotations__ object, just to give the compiler what it expects.  The compiler inserts it into the const table for the enclosing construct (function / class / module), and we remember what index it went into.  Then, after we've finished processing the entire AST tree for this module, but before we we exit the compiler, we reconstruct the required context for evaluating each __co_annotations__ function--the nested chain of symbol tables, the compiler blocks if needed, etc--and evaluate the annotations for real.  We assemble the correct __co_annotations__ code object and overwrite the stub in the const table with this now-correct value.

I can't think of any more scenarios.  So, I think I can handle basically anything!


However, there are two scenarios where the behavior of evaluations will change in a way the user might find surprising. The first is when they redefine a variable used in an annotation:

   x = str
   def fn(a:x="345"):  pass
   x = int

With stock semantics, the annotation to "a" will be "str".  With PEP 563 or my PEP, the annotation to "a" will be "int".  (It gets even more exciting if you said "del x".)

Similarly, delaying the annotations so that we make everything visible means defining variables with the same name in multiple scopes may lead to surprising behavior.

   x = str
   class Outer:
        def method(a:x="345"):  pass
        x = int

Again, stock gets you an annotation of "str", but PEP 563 and my PEP gets you "str", because they'll see the /final/ result of evaluating the body of Outer.

Sadly this is the price you pay for delayed evaluation of annotations.  Delaying the evaluation of annotations is the goal, and the whole point is to make changes, observable by the user, in how annotations are evaluated.  All we can do is document these behaviors and hope our users forgive us.


I think this is a vast improvement over the first draft of my PEP, and assuming nobody points out major flaws in this approach (and, preferably, at least a little encouragement), I plan to redesign my prototype along these lines.  (Though not right away--I want to take a break and attend to some other projects first.)


Thanks for the mind-blowing suggestions, Guido!  I must say, you're pretty good at this Python stuff.


Cheers,


//arry/

_______________________________________________
Python-Dev mailing list -- python-dev@python.org
To unsubscribe send an email to python-dev-le...@python.org
https://mail.python.org/mailman3/lists/python-dev.python.org/
Message archived at 
https://mail.python.org/archives/list/python-dev@python.org/message/5447IRH33FDAAVIEAFIUPLZVNIVOW2EB/
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to