Given your comments below, I'd summarize the semantics you want as:

   Looking up names for annotations should work exactly as it does
   today with "stock" semantics, except annotations should also see
   names that haven't been declared yet.

Thus an annotation should be able to see names set in the following scopes, in order of most-preferred to least-preferred:

 * names in the current scope (whether the current scope is a class
   body, function body, or global),
 * names in enclosing /function/ scopes, up to but not including the
   first enclosing /class/ scope, and
 * global scope,

whether they are declared before or after the annotation.

If the same name is defined multiple times, annotations will prefer the definition from the "nearest" scope, even if that definition hasn't been evaluated yet.  For example:

   x = int
   def foo():
        def bar(a:x): pass
        x = str

Here a would be annotated with "str".

Ambiguous conditions (referring to names that change value, referring to names that may be deleted) will result in undefined behavior.


Does that sound right?


Thanks for the kind words,


//arry/

On 1/15/21 12:38 PM, Guido van Rossum wrote:
On Fri, Jan 15, 2021 at 10:53 AM Larry Hastings <la...@hastings.org <mailto:la...@hastings.org>> wrote:


    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.


I suppose PEP 563 is ambiguous because on the one hand global symbols are the only things that work out of the box, on the other hand you can make other things work by passing the right scope (and there's lots of code now that does so), and on the third hand, it claims that get_type_hints() adds the class scope, which nobody noticed or implemented until this week (there's a PR, can't recall the number).

But I think all this is irrelevant given what comes below.


    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.


Yup.


    *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".


Yup.


    *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.


I think this is going too far. A static method defined in InnerCls does not see InnerCls (even after the class definitions are complete). E.g.
```
class Outer:
    class Inner:
        @staticmethod
        def foo(): return Inner
```
If you then call Outer.Inner.foo() you get "NameError: name 'Inner' is not defined".


    *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.


Probably also not needed.


    *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?


If you agree with me that (3) and (4) are unnecessary (or even undesirable), the options here are either that zebra is a local in outerfn() (then just make it a closure), and if it isn't you should treat it as a global.

    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".)


This falls under the Garbage in, Garbage out principle. Mypy doesn't even let you do this. Another type checker which is easy to install, pyright, treats it as str. I wouldn't worry too much about it. If you strike the first definition of x, the pyright complains and mypy treats it as int.

    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.


Agreed.


    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.


You're not so bad yourself -- without your wakeup call we would have immortalized PEP 563's limitations.


--
--Guido van Rossum (python.org/~guido <http://python.org/~guido>)
/Pronouns: he/him //(why is my pronoun here?)/ <http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-change-the-world/>
_______________________________________________
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/6PBHIENBFTV2YAAD3PAPCQXX5EQFTRT3/
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to