Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 37e61f1

Browse files
[codex] Improve tuple diagnostics and weak container assignability (#449)
## Summary This PR improves two related pieces of relation checking. - preserve the detailed element-level error from tuple-to-tuple comparisons instead of collapsing it to `tuple is not assignable to tuple` - allow strong containers like `list[T]` and `set[T]` to satisfy weak container literals in the invariant reverse direction when the weak container could still be empty and all unpacked members accept `T` - cover the taxonomy-shaped `defaultdict(lambda: ([], []))` case with low-level regression tests ## Why The tuple diagnostics change fixes a usability issue where nested tuple mismatches lost the useful positional explanation produced deeper in the relation machinery. The weak-container change fixes a false positive in taxonomy where annotated tuple values containing strong `list[ClassificationEntry]` entries were rejected against weak empty-list literals inferred from `defaultdict(lambda: ([], []))`. ## Impact Users now get more actionable tuple mismatch errors, and pycroscope accepts more realistic nested container patterns involving empty list or set literals without weakening the ordinary weak-literal-to-strong-container direction. ## Validation - `UV_CACHE_DIR=/tmp/uv-cache uv run --python 3.14 --extra tests pytest` - `UV_CACHE_DIR=/tmp/uv-cache uv run --python 3.14 --extra tests pytest pycroscope/test_value.py pycroscope/test_relations.py` - `UV_CACHE_DIR=/tmp/uv-cache uv run --python 3.14 python tools/taxonomy_ci.py --local-path /Users/jelle/Dropbox/code/taxonomy --install | rg "taxonomy/db/export.py at line 741|taxonomy/db/export.py at line 786|list\[ClassificationEntry\] is not assignable to <list containing \[\]>"`
1 parent e95bedf commit 37e61f1

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Fix assignability involving weak list literals in nested invariant containers, so values like `defaultdict(lambda: ([], []))` can satisfy annotated tuple-valued `dict` entries instead of failing on the inner empty-list literals.
6+
- Improve nested tuple mismatch diagnostics so assignments involving tuple-valued generics now report which tuple element failed instead of the vague message `tuple is not assignable to tuple`.
57
- Fix bare generic-class annotations so user-defined generics like `Capybara` now report `missing_generic_parameters`, and partially specialized forms like `Capybara[int]` report `invalid_specialization`.
68
- Fix `hasattr()`-driven narrowing bookkeeping so synthetic attributes no longer show up as fake unused members, and looped identity checks like `value.param_spec is ps_args.param_spec` no longer poison nearby temporary-variable reads.
79
- Fix `hasattr()`-based intersection lookup so class-object attributes like `cls.__name__` remain available after narrowing through unrelated `HasAttr[...]` predicates.

pycroscope/relations.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,34 @@ def _has_relation(
885885
if isinstance(left, SequenceValue):
886886
if isinstance(right, SequenceValue):
887887
return _has_relation_sequence(left, right, relation, ctx)
888+
if (
889+
relation is Relation.ASSIGNABLE
890+
and left.typ is not tuple
891+
and isinstance(right, GenericValue)
892+
and right.typ is left.typ
893+
and len(right.args) == 1
894+
):
895+
# In invariant comparisons we also check the reverse direction, which
896+
# can ask whether a strong container[T] satisfies a weak container
897+
# literal type. We only allow that when the weak container could
898+
# still be empty and every unpacked member type accepts T.
899+
right_member = right.args[0]
900+
bounds_maps = []
901+
for is_many, member in left.members:
902+
if not is_many:
903+
return CanAssignError(
904+
f"{right} may be empty and cannot satisfy"
905+
f" known-non-empty {left}"
906+
)
907+
can_assign = has_relation(member, right_member, relation, ctx)
908+
if isinstance(can_assign, CanAssignError):
909+
return CanAssignError(
910+
f"{right} is not {relation.description} {left}", [can_assign]
911+
)
912+
bounds_maps.append(can_assign)
913+
if not bounds_maps:
914+
return {}
915+
return unify_bounds_maps(bounds_maps)
888916
if (
889917
left.typ is tuple
890918
and isinstance(
@@ -922,6 +950,15 @@ def _has_relation(
922950
return CanAssignError(f"{right} is not {relation.description} {left}")
923951

924952
if isinstance(left, GenericValue):
953+
if (
954+
isinstance(right, SequenceValue)
955+
and left.typ is tuple
956+
and right.typ is tuple
957+
and tuple_members_from_value(left, ctx) is not None
958+
):
959+
return left.get_type_object(ctx).can_assign(
960+
left, right, ctx, relation=relation
961+
)
925962
if (
926963
relation is Relation.ASSIGNABLE
927964
and isinstance(right, DictIncompleteValue)
@@ -1787,15 +1824,27 @@ def _has_relation_sequence(
17871824
relation: Literal[Relation.SUBTYPE, Relation.ASSIGNABLE],
17881825
ctx: CanAssignContext,
17891826
) -> CanAssign:
1827+
# TypeObject.can_assign() does the nominal/container-level compatibility check
1828+
# for all sequences. For tuple/tuple, it also already performs the full
1829+
# element-by-element comparison via _compare_tuple_sequences(); preserve that
1830+
# detailed result instead of replacing it with a generic "tuple is not
1831+
# assignable to tuple" wrapper here.
17901832
can_assign = left.get_type_object(ctx).can_assign(
17911833
left, right, ctx, relation=relation
17921834
)
17931835
if isinstance(can_assign, CanAssignError):
1836+
if left.typ is tuple and right.typ is tuple:
1837+
return can_assign
17941838
return CanAssignError(
17951839
f"{stringify_object(right.typ)} is not {relation.description}"
17961840
f" {stringify_object(left.typ)}"
17971841
)
17981842

1843+
# Non-tuple sequences still need the concrete SequenceValue relation pass
1844+
# below, because the TypeObject check does not compare their known members.
1845+
if left.typ is tuple and right.typ is tuple:
1846+
return can_assign
1847+
17991848
return _compare_tuple_sequences(left, right, relation, ctx)
18001849

18011850

pycroscope/test_value.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pickle
66
import types
77
import typing
8+
from collections import defaultdict
89
from typing import NewType
910
from unittest import mock
1011

@@ -395,6 +396,74 @@ def test_sequence_value() -> None:
395396
)
396397

397398

399+
def test_nested_weak_tuple_value_arg_allows_empty_lists_in_defaultdict() -> None:
400+
class Name:
401+
pass
402+
403+
class ClassificationEntry:
404+
pass
405+
406+
left = GenericValue(
407+
dict,
408+
[
409+
TypedValue(Name),
410+
SequenceValue(
411+
tuple,
412+
[
413+
(False, GenericValue(list, [TypedValue(ClassificationEntry)])),
414+
(False, GenericValue(list, [TypedValue(ClassificationEntry)])),
415+
],
416+
),
417+
],
418+
)
419+
right = GenericValue(
420+
defaultdict,
421+
[
422+
AnyValue(AnySource.generic_argument),
423+
SequenceValue(tuple, [(False, KnownValue([])), (False, KnownValue([]))]),
424+
],
425+
)
426+
427+
assert_can_assign(left, right)
428+
429+
430+
def test_strong_list_is_assignable_to_empty_weak_list() -> None:
431+
assert_can_assign(SequenceValue(list, []), GenericValue(list, [TypedValue(int)]))
432+
433+
434+
def test_strong_list_is_assignable_to_weak_list_with_only_unpacked_members() -> None:
435+
assert_can_assign(
436+
SequenceValue(list, [(True, TypedValue(object))]),
437+
GenericValue(list, [TypedValue(int)]),
438+
)
439+
440+
441+
def test_strong_set_is_assignable_to_weak_set_with_only_unpacked_members() -> None:
442+
assert_can_assign(
443+
SequenceValue(set, [(True, TypedValue(object))]),
444+
GenericValue(set, [TypedValue(int)]),
445+
)
446+
447+
448+
def test_strong_container_is_not_assignable_to_known_non_empty_weak_literal() -> None:
449+
result = SequenceValue(list, [(False, KnownValue(1))]).can_assign(
450+
GenericValue(list, [TypedValue(int)]), CTX
451+
)
452+
453+
assert isinstance(result, CanAssignError)
454+
assert result.message == (
455+
"list[int] may be empty and cannot satisfy"
456+
" known-non-empty <list containing [Literal[1]]>"
457+
)
458+
459+
460+
def test_weak_list_is_still_assignable_to_strong_list() -> None:
461+
assert_can_assign(
462+
GenericValue(list, [TypedValue(int)]),
463+
SequenceValue(list, [(False, KnownValue(1))]),
464+
)
465+
466+
398467
def test_sequence_value_unpack() -> None:
399468
fmt_map = {"i": int, "s": str, "b": bool, "o": object}
400469

0 commit comments

Comments
 (0)