Skip to content

Commit ea83d4b

Browse files
Technologicatclaude
andcommitted
Phase 5: autoreturn match/case, scopeanalyzer bugfix, version-gated tests
- autoreturn macro now handles match/case: each case branch has its own tail position. - Fixed MatchCapturesCollector bug in scopeanalyzer: it collected class references (e.g. Point) as captures instead of actual MatchAs/MatchStar captures. Removed the collector; generic_visit + existing MatchAs/MatchStar handling covers everything except MatchMapping.rest. - Test runner supports version-suffixed modules (test_foo_3_11.py skipped on Python < 3.11). - New tests: autoreturn match/case, scopeanalyzer match/case patterns, scopeanalyzer try/except* (version-gated to 3.11+). - Changelog for 2.0.0, AUTHORS.md updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fa847a8 commit ea83d4b

File tree

9 files changed

+235
-33
lines changed

9 files changed

+235
-33
lines changed

AUTHORS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
- Juha Jeronen (@Technologicat) - original author
44
- @aisha-w - documentation improvements
5-
- @Technologicat with Claude (Anthropic) as AI pair programmer - CI modernization
5+
- @Technologicat with Claude (Anthropic) as AI pair programmer - CI modernization, Python 3.13–3.14 and mcpyrate 4.0.0 adaptation (2.0.0)
66

77
**Design inspiration from the internet**:
88

CHANGELOG.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,36 @@
11
# Changelog
22

3-
**1.0.1** (March 2026, in progress) — hotfix:
3+
**2.0.0** (March 2026, in progress) — *"Six impossible things before breakfast"* edition:
4+
5+
**IMPORTANT**:
6+
7+
- **Python version support**: 3.10–3.14 (dropped 3.8, 3.9; added 3.13, 3.14). PyPy 3.11.
8+
- If you need `unpythonic` for Python 3.8 or 3.9, use version 1.0.0.
9+
- **Requires mcpyrate >= 4.0.0**.
10+
- mcpyrate 4.0.0 dropped the `Str`, `Num`, `NameConstant` AST compatibility shims and the `getconstant` helper.
11+
12+
**New**:
13+
14+
- **Python 3.13 and 3.14 support**.
15+
- `autoreturn` macro now handles `match`/`case` statements. Each case branch has its own tail position.
16+
- New scope analyzer tests for `match`/`case` patterns and `try`/`except*`.
17+
- Test runner (`runtests.py`) now supports version-suffixed test modules (e.g. `test_foo_3_11.py` runs only on Python 3.11+).
418

519
**Fixed**:
620

21+
- Runtime type checker (`unpythonic.typecheck`): fixed compatibility with Python 3.14, where `typing.Union` is no longer a `_GenericAlias`. Now uses `typing.get_origin` (available since 3.8) instead of a local copy.
22+
- Runtime type checker: fixed `TypeVar` detection to use `isinstance(T, typing.TypeVar)` instead of a fragile `repr`-based heuristic.
23+
- Runtime type checker: `typing.Reversible` check now uses `isinstance` instead of a `hasattr("__reversed__")` workaround from the Python 3.5 era.
24+
- Runtime type checker: removed redundant `safeissubclass` fallbacks for generic types — `typing.get_origin` handles both bare and parameterized generics on 3.10+.
25+
- Scope analyzer: fixed `MatchCapturesCollector` bug where class references (e.g. `Point` in `case Point(x, y):`) were incorrectly collected as captured variable names. Match captures are `MatchAs`/`MatchStar` nodes with bare strings, not `Name` nodes.
26+
- Macro layer: updated all `hasattr(tree, "ctx")` checks to use `getattr` with defaults, for correct behavior on Python 3.13+ where AST fields always exist with default values.
27+
- Macro layer: updated `arguments()` constructor calls to always include `posonlyargs=[]`, avoiding a `DeprecationWarning` on Python 3.13 (will become an error in 3.15).
728
- MS Windows: `unpythonic.net.util` failed to load, due to missing `termios` module (which is *nix only) being loaded by `unpythonic.net.__init__` when it imports `unpythonic.net.ptyproxy`.
829
- Fixed by catching `ModuleNotFoundError`, disabling `ptyproxy` on MS Windows systems.
9-
- This means that the live REPL server is not available on MS Windows. This is usually harmless, as most applications using `unpythonic` do not need it.
30+
31+
**Deprecated**:
32+
33+
- Parenthesis syntax for macro arguments (e.g. `let((x, 1), (y, 2))`). Use bracket syntax instead: `let[[x, 1], [y, 2]]`. The parenthesis syntax is kept for backward compatibility but may be removed in a future version.
1034

1135

1236
---

TODO_DEFERRED.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Deferred Issues
2+
3+
- **D1**: Document pyc cache pitfall and test result reading for other projects using mcpyrate/unpythonic.test.fixtures. The CLAUDE.md additions from the 2.0.0 modernization (never `py_compile` macro-enabled code; how to read test framework output with Pass/Fail/Error) are useful guidance for any project using these tools. Consider adding similar notes to mcpyrate's docs and/or unpythonic's user-facing documentation. (Discovered during Phase 3.)
4+
5+
- **D4**: `typecheck.py` — expand runtime type checker to support more `typing` features: NamedTuple, DefaultDict, Counter, ChainMap, OrderedDict, IO/TextIO/BinaryIO, Pattern/Match, Generic, Type, Awaitable, Coroutine, AsyncIterable, AsyncIterator, ContextManager, AsyncContextManager, Generator, AsyncGenerator, NoReturn, ClassVar, Final, Protocol, TypedDict, Literal, ForwardRef. Would improve `unpythonic.dispatch` (multiple dispatch). (Discovered during Phase 4 cleanup.)
6+
7+
- **D5**: `runtests.py` — version-suffix skip should signal `TestWarning` (via `unpythonic.conditions.signal`) instead of printing and continuing. This would make skips visible in the testset warning count, consistent with how optional dependency failures show as errors. Currently the skip message bypasses the testset reporting mechanism. (Discovered during Phase 5.)

runtests.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
import sys
1212
from importlib import import_module
1313

14-
from unpythonic.test.fixtures import session, testset, tests_errored, tests_failed
14+
from unpythonic.test.fixtures import (session, testset, maybe_colorize,
15+
tests_errored, tests_failed, TestConfig)
1516
from unpythonic.collections import unbox
1617

18+
from mcpyrate.colorizer import Style
19+
1720
import mcpyrate.activate # noqa: F401
1821

1922
def listtestmodules(path):
@@ -29,6 +32,17 @@ def modname(path, filename): # some/dir/mod.py --> some.dir.mod
2932
themod = re.sub(r"\.py$", r"", filename)
3033
return ".".join([modpath, themod])
3134

35+
def _version_suffix(modulename):
36+
"""Parse version suffix from module name.
37+
38+
E.g. ``unpythonic.syntax.tests.test_scopeanalyzer_3_11`` → ``(3, 11)``, or ``None``.
39+
"""
40+
# Match the final component of a dotted module name.
41+
m = re.search(r"_(\d+)_(\d+)$", modulename)
42+
if m:
43+
return (int(m.group(1)), int(m.group(2)))
44+
return None
45+
3246
def main():
3347
with session():
3448
# All folders containing unit tests are named `tests` (plural).
@@ -46,6 +60,13 @@ def main():
4660
# Wrap each module in its own testset to protect the umbrella testset
4761
# against ImportError as well as any failures at macro expansion time.
4862
with testset(m):
63+
ver = _version_suffix(m)
64+
if ver is not None and sys.version_info < ver:
65+
msg = (f"Skipping '{m}' (requires Python {ver[0]}.{ver[1]}+, "
66+
f"running {sys.version_info.major}.{sys.version_info.minor})")
67+
TestConfig.printer(maybe_colorize(msg, Style.DIM,
68+
TestConfig.ColorScheme.HEADING))
69+
continue
4970
# TODO: We're not inside a package, so we currently can't use a relative import.
5071
# TODO: So we just hope this resolves to the local `unpythonic` source code,
5172
# TODO: not to an installed copy of the library.

unpythonic/syntax/scopeanalyzer.py

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
Import, ImportFrom, Try, ListComp, SetComp, GeneratorExp,
8181
DictComp, Store, Del, Global, Nonlocal)
8282

83-
from mcpyrate.astcompat import TryStar, MatchStar, MatchMapping, MatchClass, MatchAs
83+
from mcpyrate.astcompat import TryStar, MatchStar, MatchMapping, MatchAs
8484
from mcpyrate.core import Done
8585
from mcpyrate.walkers import ASTTransformer, ASTVisitor
8686

@@ -316,12 +316,6 @@ def get_names_in_store_context(tree):
316316
by ``get_lexical_variables`` for the nearest lexically surrounding parent
317317
tree that represents a scope.
318318
"""
319-
class MatchCapturesCollector(ASTVisitor): # Python 3.10+: `match`/`case`
320-
def examine(self, tree):
321-
if type(tree) is Name:
322-
self.collect(tree.id)
323-
self.generic_visit(tree)
324-
325319
class StoreNamesCollector(ASTVisitor):
326320
# def _collect_name_or_list(self, t):
327321
# if type(t) is Name:
@@ -355,28 +349,20 @@ def examine(self, tree):
355349
# TODO: `try`, even inside the `except` blocks, will be bound in the whole parent scope.
356350
for h in tree.handlers:
357351
self.collect(h.name)
358-
# Python 3.10+: `match`/`case` uses names in `Load` context to denote captures.
359-
# Also there are some bare strings, and sometimes `None` actually means "_" (but doesn't capture).
360-
# So we special-case all of this.
361-
elif type(tree) in (MatchAs, MatchStar): # a `MatchSequence` also consists of these
352+
# Python 3.10+: `match`/`case` captures are `MatchAs(name='x')` and
353+
# `MatchStar(name='rest')` with bare strings (not `Name` nodes). The `name`
354+
# is `None` for `_` (wildcard, doesn't capture). `Name` nodes in patterns are
355+
# class references (e.g. `Point` in `case Point(x, y):`), not captures.
356+
#
357+
# `generic_visit` handles most match patterns automatically, since `MatchAs`
358+
# and `MatchStar` nodes appear as children. The one exception is
359+
# `MatchMapping.rest`, which is a bare string attribute (not an AST child).
360+
elif type(tree) in (MatchAs, MatchStar):
362361
if tree.name is not None:
363362
self.collect(tree.name)
364363
elif type(tree) is MatchMapping:
365-
mcc = MatchCapturesCollector(tree.patterns)
366-
mcc.visit()
367-
for name in mcc.collected:
368-
self.collect(name)
369-
if tree.rest is not None: # `rest` is a capture if present
364+
if tree.rest is not None: # `**rest` capture
370365
self.collect(tree.rest)
371-
elif type(tree) is MatchClass:
372-
mcc = MatchCapturesCollector(tree.patterns)
373-
mcc.visit()
374-
for name in mcc.collected:
375-
self.collect(name)
376-
mcc = MatchCapturesCollector(tree.kwd_patterns)
377-
mcc.visit()
378-
for name in mcc.collected:
379-
self.collect(name)
380366

381367
# Python 3.12+: `TypeAlias` uses a name in `Store` context on its LHS so it needs no special handling here.
382368

unpythonic/syntax/tailtools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
List, Tuple,
1515
Call, Name, Starred, Constant,
1616
BoolOp, And, Or,
17-
With, AsyncWith, If, IfExp, Try, Assign, Return, Expr,
17+
With, AsyncWith, If, IfExp, Try, Match, Assign, Return, Expr,
1818
Await,
1919
copy_location)
2020

@@ -699,6 +699,10 @@ def transform(self, tree):
699699
# additionally, tail position is in each "except" handler
700700
for handler in tree.handlers:
701701
handler.body[-1] = self.visit(handler.body[-1])
702+
elif type(tree) is Match: # Python 3.10+: `match`/`case`
703+
for case in tree.cases:
704+
if case.body:
705+
case.body[-1] = self.visit(case.body[-1])
702706
elif type(tree) in (FunctionDef, AsyncFunctionDef, ClassDef): # v0.15.0+
703707
# If the item in tail position is a named function definition
704708
# or a class definition, it binds a name - that of the function/class.

unpythonic/syntax/tests/test_autoret.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,44 @@ class InnerClassDefinition:
8787
test[isinstance(classdefiner(), type)] # returned a class
8888
test[classdefiner().__name__ == "InnerClassDefinition"]
8989

90+
with testset("match/case"): # Python 3.10+
91+
with autoreturn:
92+
def classify(x):
93+
match x:
94+
case 1:
95+
"one"
96+
case 2:
97+
"two"
98+
case _:
99+
"other"
100+
test[classify(1) == "one"]
101+
test[classify(2) == "two"]
102+
test[classify(42) == "other"]
103+
104+
def classify_nested(x):
105+
match x:
106+
case (a, b):
107+
a + b
108+
case [a, b, *rest]:
109+
a + b + sum(rest)
110+
case _:
111+
0
112+
test[classify_nested((3, 4)) == 7]
113+
test[classify_nested([1, 2, 3, 4]) == 10]
114+
test[classify_nested("nope") == 0]
115+
116+
def classify_with_guard(x):
117+
match x:
118+
case n if n < 0:
119+
"negative"
120+
case 0:
121+
"zero"
122+
case n if n > 0:
123+
"positive"
124+
test[classify_with_guard(-5) == "negative"]
125+
test[classify_with_guard(0) == "zero"]
126+
test[classify_with_guard(7) == "positive"]
127+
90128
if __name__ == '__main__': # pragma: no cover
91129
with session(__file__):
92130
runtests()

unpythonic/syntax/tests/test_scopeanalyzer.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@
1414
get_lexical_variables,
1515
scoped_transform)
1616

17-
# TODO: Add tests for `match`/`case` once we bump minimum language version to Python 3.10.
18-
# TODO: Add tests for `try`/`except*` once we bump minimum language version to Python 3.11.
19-
2017
def runtests():
2118
# test data
2219
with q as getnames_load:
@@ -272,6 +269,82 @@ def f(): # noqa: F811
272269
n["_apply_test_here_"]
273270
scoped_transform(scoped_localvar3, callback=make_checker(["f"])) # x already deleted
274271

272+
# Python 3.10+: `match`/`case`
273+
with testset("match/case: get_names_in_store_context"):
274+
# Simple capture
275+
with q as matchcase_simple:
276+
match x: # noqa: F821, it's only quoted.
277+
case y: # noqa: F841, it's only quoted.
278+
pass
279+
test[get_names_in_store_context(matchcase_simple) == ["y"]]
280+
281+
# Wildcard `_` — does NOT capture
282+
with q as matchcase_wildcard:
283+
match x: # noqa: F821, it's only quoted.
284+
case _:
285+
pass
286+
test[get_names_in_store_context(matchcase_wildcard) == []]
287+
288+
# Sequence pattern with star capture
289+
with q as matchcase_sequence:
290+
match x: # noqa: F821, it's only quoted.
291+
case [a, b, *rest]: # noqa: F841, it's only quoted.
292+
pass
293+
test[get_names_in_store_context(matchcase_sequence) == ["a", "b", "rest"]]
294+
295+
# Class pattern — captures `x` and `y`, but NOT the class reference `Point`
296+
with q as matchcase_class:
297+
match x: # noqa: F821, it's only quoted.
298+
case Point(x, y): # noqa: F821, F841, it's only quoted.
299+
pass
300+
names = get_names_in_store_context(matchcase_class)
301+
test["x" in names]
302+
test["y" in names]
303+
test["Point" not in names] # class reference, not a capture
304+
305+
# Class pattern with keyword captures
306+
with q as matchcase_class_kw:
307+
match x: # noqa: F821, it's only quoted.
308+
case Point(x=px, y=py): # noqa: F821, F841, it's only quoted.
309+
pass
310+
names = get_names_in_store_context(matchcase_class_kw)
311+
test["px" in names]
312+
test["py" in names]
313+
test["Point" not in names]
314+
315+
# Mapping pattern with `**rest`
316+
with q as matchcase_mapping:
317+
match x: # noqa: F821, it's only quoted.
318+
case {"key": value, **rest}: # noqa: F841, it's only quoted.
319+
pass
320+
names = get_names_in_store_context(matchcase_mapping)
321+
test["value" in names]
322+
test["rest" in names]
323+
324+
# Nested: mapping containing a class pattern
325+
with q as matchcase_nested:
326+
match x: # noqa: F821, it's only quoted.
327+
case {"key": Point(px, py)}: # noqa: F821, F841, it's only quoted.
328+
pass
329+
names = get_names_in_store_context(matchcase_nested)
330+
test["px" in names]
331+
test["py" in names]
332+
test["Point" not in names] # class reference, not a capture
333+
334+
# OR pattern
335+
with q as matchcase_or:
336+
match x: # noqa: F821, it's only quoted.
337+
case 1 | 2 | 3:
338+
pass
339+
test[get_names_in_store_context(matchcase_or) == []]
340+
341+
# `as` pattern with guard
342+
with q as matchcase_as:
343+
match x: # noqa: F821, it's only quoted.
344+
case (1 | 2) as num: # noqa: F841, it's only quoted.
345+
pass
346+
test[get_names_in_store_context(matchcase_as) == ["num"]]
347+
275348
if __name__ == '__main__': # pragma: no cover
276349
with session(__file__):
277350
runtests()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -*- coding: utf-8 -*-
2+
"""Lexical scope analysis tools — try/except* tests.
3+
4+
These tests require Python 3.11+ because the ``except*`` syntax
5+
won't parse on earlier versions.
6+
7+
TODO: Merge into test_scopeanalyzer.py when floor bumps to Python 3.11+.
8+
"""
9+
10+
from ...syntax import macros, test, test_raises, the # noqa: F401
11+
from ...test.fixtures import session, testset
12+
13+
from mcpyrate.quotes import macros, q # noqa: F401, F811
14+
15+
from ...syntax.scopeanalyzer import get_names_in_store_context
16+
17+
def runtests():
18+
with testset("try/except*: get_names_in_store_context"):
19+
# except* binds names just like except
20+
with q as exceptstar_simple:
21+
try:
22+
pass
23+
except* ValueError as eg: # noqa: F841, it's only quoted.
24+
pass
25+
test[get_names_in_store_context(exceptstar_simple) == ["eg"]]
26+
27+
with q as exceptstar_multi:
28+
try:
29+
pass
30+
except* ValueError as eg1: # noqa: F841, it's only quoted.
31+
pass
32+
except* TypeError as eg2: # noqa: F841, it's only quoted.
33+
pass
34+
test[get_names_in_store_context(exceptstar_multi) == ["eg1", "eg2"]]
35+
36+
# Names bound inside the try body are also collected
37+
with q as exceptstar_with_assign:
38+
try:
39+
x = 42 # noqa: F841, it's only quoted.
40+
except* ValueError as eg: # noqa: F841, it's only quoted.
41+
y = 1 # noqa: F841, it's only quoted.
42+
names = get_names_in_store_context(exceptstar_with_assign)
43+
test["x" in the[names]]
44+
test["y" in the[names]]
45+
test["eg" in the[names]]
46+
47+
if __name__ == '__main__': # pragma: no cover
48+
with session(__file__):
49+
runtests()

0 commit comments

Comments
 (0)