Skip to content

Do not erase a meaningful empty tuple[()] subscript in the transformer - #565

Merged
agronholm merged 3 commits into
agronholm:masterfrom
chuenchen309:empty-tuple-erasure
Jul 23, 2026
Merged

Do not erase a meaningful empty tuple[()] subscript in the transformer#565
agronholm merged 3 commits into
agronholm:masterfrom
chuenchen309:empty-tuple-erasure

Conversation

@chuenchen309

Copy link
Copy Markdown
Contributor

Summary

The empty-tuple annotation tuple[()] is silently erased to bare tuple by the AST transformer, so the import hook and @typechecked (which share the transformer) wrongly accept any tuple for a tuple[()] parameter. check_type — which does not go through the transformer — correctly rejects it. Two of typeguard's three checking paths disagree with the third, and the third is the correct one.

from typeguard import typechecked, check_type

check_type((1,), tuple[()])          # correctly raises "tuple is not an empty tuple"

@typechecked
def f(x: tuple[()]): ...
f((1,))                              # BUG: accepted (should raise); same via install_import_hook
path before after
check_type reject (correct) reject
@typechecked (source annotation) accept — wrong reject
import hook accept — wrong reject

Root cause

src/typeguard/_transformer.py, AnnotationTransformer.visit_Subscript:

# If all items in the subscript were Any, erase the subscript entirely
if all(item is None for item in items):
    return node.value

For tuple[()] the AST slice is Tuple(elts=[]), so items == [] and all([]) is vacuously True — the transformer rewrites tuple[()] to tuple, dropping the constraint. (The instrumented source confirms it: the annotation is emitted as {'x': (x, tuple)} with the [()] gone.) typeguard's checker itself has explicit empty-tuple support (_checkers.py, "is not an empty tuple"), so the intended verdict is unambiguous.

Fix

Guard the erasure so it only fires for a non-empty all-Any subscript:

# If all items in the subscript were Any, erase the subscript entirely
# (but not for an empty subscript like tuple[()], which is meaningful)
if items and all(item is None for item in items):
    return node.value

Genuine all-Any erasure (e.g. dict[Any, Any]) is unchanged.

Verification

  • New test_empty_tuple / test_empty_tuple_fail in tests/test_instrumentation.py run under both the typechecked and importhook parametrizations. On the unfixed tree test_empty_tuple_fail fails for both (a non-empty tuple is wrongly accepted); with the fix all 4 pass.
  • test_transformer.py test_instrumentation.py test_checkers.py test_typechecked.py: 495 passed, 7 skipped, 9 xfailed, no failures. Full tests/: 523 passed (the only 2 failures are tests/mypy/ FileNotFoundError: 'mypy' — mypy not installed in my env, unrelated).
  • Changelog entry added in docs/versionhistory.rst.

This PR was authored by an AI coding agent (Claude Code) running on this account: the AI found the bug, ran the repro, wrote the tests, and wrote this description. The human account holder reviews every change and is accountable for it. The verification above is real and re-runnable from the diff. If this isn't the kind of contribution you want, say so and I'll close it.

The AST transformer erased a subscript when all its items resolved to Any,
using `all(item is None for item in items)`. For `tuple[()]` the slice is
an empty tuple, so `items` is empty and `all([])` is vacuously True — the
transformer rewrote `tuple[()]` down to bare `tuple`, discarding the
empty-tuple constraint. Both the import hook and @TypeChecked (which share
the transformer) then accepted any tuple for a `tuple[()]` annotation,
while check_type — which does not go through the transformer — correctly
rejected it. Guard the erasure with `items and ...` so an empty subscript
is preserved.

Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com>
@coveralls

coveralls commented Jul 19, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 94.652%. remained the same — chuenchen309:empty-tuple-erasure into agronholm:master

Comment thread docs/versionhistory.rst

@agronholm agronholm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@agronholm
agronholm merged commit a319de1 into agronholm:master Jul 23, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants