[syntax-errors] Document behavior of global declarations in try nodes before 3.13#17285
[syntax-errors] Document behavior of global declarations in try nodes before 3.13#17285
global declarations in try nodes before 3.13#17285Conversation
|
I just realized that this might need to be gated behind preview since it modifies an existing, stable lint rule. Alternatively, I could just use a different |
|
|
Oh, there's a false positive anyway, moving back to draft. |
|
The false positive should now be handled by tracking a scope depth in the source-order visitor to avoid flagging more-deeply-nested references. I think functions and classes are the only ways to introduce this kind of scope, in which I think |
86347a5 to
d158e91
Compare
ruff/crates/ruff_python_semantic/src/scope.rs Lines 188 to 194 in 058439d |
This is confusing, do we have a source like a changelog entry where the related change is mentioned for context? It might also be useful to add that as comment in the source code. |
| for stmt in body { | ||
| SourceOrderVisitor::visit_stmt(&mut visitor, stmt); | ||
| } |
There was a problem hiding this comment.
We should use the visit_body method instead in here, orelse, handlers, and finalbody.
There was a problem hiding this comment.
except for the "handlers" loop
There was a problem hiding this comment.
I think the easiest here is to call walk_stmt(&visitor, stmt) because this is an exact reimplementation of visiting a try statement.
There was a problem hiding this comment.
walk_stmt would only match one of these orders, right? We need to swap the order of the orelse and handlers visits depending on the version. I could still use it in one of the branches and then inline the whole modified version in the other branch.
There was a problem hiding this comment.
You're right, we traverse into function statements but we increase the scope counter. I have to take another look to understand what the scope counter is even used for but I suspect that different rules apply for names and global statements inside nested functions, classes and it's unclear to me if that also applies to lambdas (can't have any global statements but it may have identifiers).
So what I'm looking for is a test that demonstrates the behavior when lambdas are involved.
This is the bug report Alex linked to in #11934. I'll look for anything else and at least link to this in the enum variant docs! |
I think just linking to the CPython issue should be enough. |
|
(I need to head out now, I'll continue with the review later in the evening as I need to look at it a bit more closely to understand.) |
Can you tell me more in how the new implementation diverges from the existing one? |
MichaReiser
left a comment
There was a problem hiding this comment.
We should add a few more tests and explore if there's a way to avoid duplicating the globals handling between the semantic syntax checker and the caller.
| finalbody, | ||
| is_star: _, | ||
| }) => { | ||
| let mut visitor = TryExceptVisitor::new(ctx); |
There was a problem hiding this comment.
Using a custom visitor here is probably fine because try statements aren't too deeply nested (they rarely contain nested functions) but this is now a case where the visitor ends up visiting entire suites multiple times:
- In the
TryExceptvisitor - The outer visitor
I've a weak preference to avoid the double visitation unless it increases complexity by a lot
There was a problem hiding this comment.
I'll give this a try (no pun intended). The nice thing about the visitor is that it let me control the order in which things are seen, and I could just swap the order of the visits on different Python versions. I think that order-dependence might be hard to capture otherwise, but I'll play with it a bit.
The easiest thing to do would be to move that reordering into the parent visitor, but that seems too intrusive.
| for stmt in body { | ||
| SourceOrderVisitor::visit_stmt(&mut visitor, stmt); | ||
| } |
There was a problem hiding this comment.
I think the easiest here is to call walk_stmt(&visitor, stmt) because this is an exact reimplementation of visiting a try statement.
|
|
||
| fn visit_stmt(&mut self, stmt: &Stmt) { | ||
| match stmt { | ||
| Stmt::FunctionDef(_) | Stmt::ClassDef(_) => { |
There was a problem hiding this comment.
What about nested try statements?
There was a problem hiding this comment.
I don't think try introduces a new scope:
>>> try:
... x = 1
... except:
... pass
...
>>> x
1
>>>I think only functions and classes do. Is that what you were asking?
| } | ||
|
|
||
| fn visit_expr(&mut self, expr: &Expr) { | ||
| if let Expr::Name(ast::ExprName { range, id, ctx: _ }) = expr { |
There was a problem hiding this comment.
What about lambda expressions?
There was a problem hiding this comment.
Could you expand on that? I thought the SourceOrderVisitor would handle descending into lambda expressions for me. Or do they have another way of accessing an identifier that's not covered here?
There was a problem hiding this comment.
Yes, the visitor traverses into lambdas. The question is whether it should (e.g. we don't traverse into nested functions or classes)
There was a problem hiding this comment.
I might be getting confused here. Why don't we traverse into nested functions or classes? I thought the source_order::walk_stmt call in visit_stmt below would handle that.
The idea here was just to collect all identifiers and then compare them to the identifiers in any following global statements. If I missed traversing any kind of node that can contain identifiers, then I think it's a mistake, not my intention.
It sounds like I should add more tests with nested structures in any case.
|
|
||
| fn visit_expr(&mut self, expr: &Expr) { | ||
| if let Expr::Name(ast::ExprName { range, id, ctx: _ }) = expr { | ||
| self.add_identifier(id.clone(), *range); |
There was a problem hiding this comment.
I find it unfortunate that we're collecting all identifiers here, even if there isn't a single global statement in the program (which should be the vast majority of programs).
It also has the downside that globals handling is now handled here but also something that we expect the caller to handle (context.global(name)). Could we instead make use that context already provides a global method or extend the context interface to avoid doing this work twice?
| struct TryExceptVisitor<'a, Ctx> { | ||
| /// Context used for emitting errors. | ||
| ctx: &'a Ctx, | ||
| identifiers: FxHashMap<ast::name::Name, Ident>, |
There was a problem hiding this comment.
We should store name references here (you have the 'a lifetime anyway)
The divergence is just the version-dependence of the branch visiting order. From the summary:
Phrasing it that way, it clearly sounds like a bug, but I think the old behavior was so unintuitive that probably nobody runs into it |
Summary
--
This PR extends the `LoadBeforeGlobalDeclaration` check, which corresponds to
PLE0118 in ruff, to handle Python versions before 3.13 properly.
On Python 3.12, the `else` clause is visited before the `except` handlers:
```pycon
Python 3.12.9 (main, Feb 12 2025, 14:50:50) [Clang 19.1.6 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 10
>>> def g():
... try:
... 1 / 0
... except:
... a = 1
... else:
... global a
...
>>> def f():
... try:
... pass
... except:
... global a
... else:
... print(a)
...
File "<stdin>", line 5
SyntaxError: name 'a' is used prior to global declaration
```
The order is swapped on 3.13:
```pycon
Python 3.13.2 (main, Feb 5 2025, 08:05:21) [GCC 14.2.1 20250128] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 10
... def g():
... try:
... 1 / 0
... except:
... a = 1
... else:
... global a
...
File "<python-input-0>", line 8
global a
^^^^^^^^
SyntaxError: name 'a' is assigned to before global declaration
>>> def f():
... try:
... pass
... except:
... global a
... else:
... print(a)
...
>>>
```
The current implementation of PLE0118 is correct for 3.13 but not 3.12:
[playground](https://play.ruff.rs/d7467ea6-f546-4a76-828f-8e6b800694c9) (it
flags the first case regardless of Python version).
Test Plan
--
New inline tests that @AlexWaygood helped me write in our pairing session!
d158e91 to
4f739f7
Compare
|
Based on our discussion on Discord, I reverted all of the code changes here and simply extended the rule variant documentation to reflect our decision to enforce the 3.13 clause ordering on all Python versions. I initially planned to keep the inline tests to demonstrate this, but they would now rely on a proper implementation of |
|
The |
MichaReiser
left a comment
There was a problem hiding this comment.
Thanks and sorry for not raising this sooner
|
No problem at all, I thought it was a useful discussion! |
global declarations in try nodes before 3.13global declarations in try nodes before 3.13
…odes before 3.13 (astral-sh#17285) Summary -- This PR extends the documentation of the `LoadBeforeGlobalDeclaration` check to specify the behavior on versions of Python before 3.13. Namely, on Python 3.12, the `else` clause of a `try` statement is visited before the `except` handlers: ```pycon Python 3.12.9 (main, Feb 12 2025, 14:50:50) [Clang 19.1.6 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> a = 10 >>> def g(): ... try: ... 1 / 0 ... except: ... a = 1 ... else: ... global a ... >>> def f(): ... try: ... pass ... except: ... global a ... else: ... print(a) ... File "<stdin>", line 5 SyntaxError: name 'a' is used prior to global declaration ``` The order is swapped on 3.13 (see [CPython#111123](python/cpython#111123)): ```pycon Python 3.13.2 (main, Feb 5 2025, 08:05:21) [GCC 14.2.1 20250128] on linux Type "help", "copyright", "credits" or "license" for more information. >>> a = 10 ... def g(): ... try: ... 1 / 0 ... except: ... a = 1 ... else: ... global a ... File "<python-input-0>", line 8 global a ^^^^^^^^ SyntaxError: name 'a' is assigned to before global declaration >>> def f(): ... try: ... pass ... except: ... global a ... else: ... print(a) ... >>> ``` The current implementation of PLE0118 is correct for 3.13 but not 3.12: [playground](https://play.ruff.rs/d7467ea6-f546-4a76-828f-8e6b800694c9) (it flags the first case regardless of Python version). We decided to maintain this incorrect diagnostic for Python versions before 3.13 because the pre-3.13 behavior is very unintuitive and confirmed to be a bug, although the bug fix was not backported to earlier versions. This can lead to false positives and false negatives for pre-3.13 code, but we also expect that to be very rare, as demonstrated by the ecosystem check (before the version-dependent check was reverted here). Test Plan -- N/a
Summary
This PR extends the documentation of the
LoadBeforeGlobalDeclarationcheck to specify the behavior on versions of Python before 3.13. Namely, on Python 3.12, theelseclause of atrystatement is visited before theexcepthandlers:The order is swapped on 3.13 (see CPython#111123):
The current implementation of PLE0118 is correct for 3.13 but not 3.12: playground (it flags the first case regardless of Python version).
We decided to maintain this incorrect diagnostic for Python versions before 3.13 because the pre-3.13 behavior is very unintuitive and confirmed to be a bug, although the bug fix was not backported to earlier versions. This can lead to false positives and false negatives for pre-3.13 code, but we also expect that to be very rare, as demonstrated by the ecosystem check (before the version-dependent check was reverted here).
Test Plan
N/a