fix[ux]: show readable type names in type-mismatch errors - #5202
fix[ux]: show readable type names in type-mismatch errors#5202cristianizzo wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves Vyper’s type-mismatch UX by ensuring _GenericTypeAcceptor (used in builtin input type definitions) renders as a user-facing type name in error messages, avoiding leakage of internal Python class reprs as reported in #4955.
Changes:
- Add
__str__to_GenericTypeAcceptorto emit readable, user-facing type identifiers for error formatting. - Add functional regression tests asserting
len()and an indexing type-mismatch no longer includeGenericTypeAcceptor(...)and instead show readable expected-type names.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| vyper/semantics/types/base.py | Adds _GenericTypeAcceptor.__str__ to produce user-facing names in type-mismatch messages. |
| tests/functional/syntax/test_len.py | Adds regression tests for readable expected-type names and no GenericTypeAcceptor leakage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`_GenericTypeAcceptor` rendered as `GenericTypeAcceptor(<class '...StringT'>)` in error messages (e.g. `len()` on a wrong type or module), leaking internal Python class paths instead of readable Vyper type names. Add a `__str__` that resolves the user-facing name (`String`, `Bytes`, `DynArray`, ...), falling back to `typeclass` then the class name, and keep `__repr__` as the debug form. Fixes vyperlang#4955
ee1808b to
4f4f1ca
Compare
Sporarum
left a comment
There was a problem hiding this comment.
Thank you for your work!
A couple points:
Given the tests are explicitly about error messages, I would prefer the check was of the form assert message == to have the full expected content on display, you can look how we did it in some other tests
(notably I don't think we do str(exc_info.value))
| def __str__(self): | ||
| # User-facing type name (e.g. `String`, `Bytes`, `DynArray`) used in | ||
| # error messages, instead of the internal Python class repr. `_id` is a | ||
| # plain class attribute on most types, but a property on parametric | ||
| # types (e.g. `IntegerT`, `BytesM_T`), so fall back to `typeclass` | ||
| # (mapped to its user-facing spelling), then the class name, keeping | ||
| # this total (never returns a non-string). | ||
| name = getattr(self.type_, "_id", None) | ||
| if not isinstance(name, str): | ||
| typeclass = getattr(self.type_, "typeclass", None) | ||
| name = _TYPECLASS_DISPLAY_NAMES.get(typeclass, typeclass) | ||
| if not isinstance(name, str): | ||
| name = self.type_.__name__ | ||
| return name |
There was a problem hiding this comment.
I'm not sure the typeclass is a good default name (somewhat highlighted by the need for _TYPECLASS_DISPLAY_NAMES), as far as I understand it's the name used when linearizing to json, and thus needs to be unique for us, but not a good user-facing name.
And the user-facing name might evolve differently than the typeclass, for backward compat reasons
I'm not sure what the "correct" solution is then, maybe have _id also work on String etc ?
There was a problem hiding this comment.
Agreed — I dropped typeclass from the display path entirely (it is the json-linearization id and shouldn't double as a user-facing name), along with the _TYPECLASS_DISPLAY_NAMES map and the __name__ fallback.
Instead, __str__ now resolves the class-level _id first and falls back to a new explicit _generic_id:
name = getattr(self.type_, "_id", None)
if not isinstance(name, str):
name = self.type_._generic_id
if not isinstance(name, str):
raise CompilerPanic(f"{self.type_.__name__} has no user-facing name")_generic_id: str = None # type: ignore is declared on VyperType (mirroring the adjacent typeclass declaration) and set on exactly the three types that .any() can hand us without a class-level _id: BytesM_T (_id is a property) → "bytesM", IntegerT (_id is a cached_property) → "integer", and TYPE_T (no _id at all) → "type". I checked every type reachable via .any() and they all now resolve to a readable name, so the CompilerPanic is unreachable but keeps a future parametric type from silently leaking a class repr instead of quietly degrading.
I also switched the tests to full-message equality as you asked (assert e.value.message == ..., no str(exc_info.value) — that appends the source location), e.g.:
assert e.value.message == "Given reference has type int128, expected one of String, Bytes, DynArray"There was a problem hiding this comment.
Why does _id on String (et al) work ?
Since if anything String is more parametrized than IntegerT
There was a problem hiding this comment.
Good question, and the answer showed my comment was wrong (fixed below).
It is not about parametricity. It is about what _id names: the type constructor, or the fully applied type.
StringT _id = "String" (class attr) __repr__ -> "String[5]"
BytesT _id = "Bytes" (class attr) __repr__ -> "Bytes[5]"
DArrayT _id = "DynArray" (class attr) __repr__ -> "DynArray[uint256, 3]"
IntegerT _id = cached_property -> "uint256"
BytesM_T _id = property -> "bytes4"
So String is parameterized, as you say, but its _id holds the constructor name and the parameters are appended in __repr__. IntegerT/BytesM_T instead bake the parameters into _id itself, so there is no constructor-level name to read off the class — which is exactly what .any() needs. That is the gap _generic_id fills, and I have reworded the declaration comment accordingly.
There was a problem hiding this comment.
Okay thanks, then I think we should instead do the same, and have _id not have parameters baked-in, and the repr outputing the full name
|
Yeah, fair point. The reason it breaks is just that So instead of touching class BytesM_T(_PrimT):
_generic_id = "bytesM"
class IntegerT(...):
_generic_id = "integer"and make Or if you'd rather make |
Not sure I understand, but I think we should use
I'm not sure what that would entail, if it can be done cleanly, it conceptually looks like the cleaner option |
|
Yeah, On dropping On folding it into I'll push that (flip the order, add |
Address review: `typeclass` is the json-serialization id, not a user-facing name, so reusing it (plus a `_TYPECLASS_DISPLAY_NAMES` map) for display was the wrong layer. Give the types whose `_id` is a property (`BytesM_T`, `IntegerT`) or absent (`TYPE_T`) an explicit `_generic_id`, and resolve `_id` -> `_generic_id` in `_GenericTypeAcceptor.__str__`, dropping the `typeclass` map and the `__name__` fallback.
Compare `e.value.message` against the complete expected string instead of substring checks, per review feedback, so the exact user-facing message is visible in the test.
| # `concat`'s accepted `bytesM` type must be shown by its user-facing name, | ||
| # not the internal `bytes_m` typeclass (nor a `GenericTypeAcceptor` repr). | ||
| # See issue #4955. |
There was a problem hiding this comment.
Nit:
I think the added tests are small enough they don't need an explanation
There was a problem hiding this comment.
Removed from both tests. The one comment I kept is a single line on the index test, noting it is the _generic_id path rather than the _id path, since that is not obvious from reading the contract snippet.
| # position, ex. constructors (events, interfaces and structs), and also | ||
| # certain builtins which take types as parameters | ||
| class TYPE_T(VyperType): | ||
| _generic_id = "type" |
There was a problem hiding this comment.
Hmm this doesn't seem great, can you add a test so we can see what the error looks like ?
I think TYPE_T should instead use the name of the underlying object (event, interface, etc)
There was a problem hiding this comment.
You were right that this was not great — I removed TYPE_T._generic_id entirely.
I could not produce an error that displays it. I tried every TYPE_T.any() call site with a variety of bad arguments:
empty(1) InvalidType: '1' is not a type!
empty(self.bar) InvalidType: 'self.bar' is not a type!
empty("uint256") InvalidType: '"uint256"' is not a type!
epsilon(b"ab") InvalidType: 'b"ab"' is not a type!
abi_decode(b, b"ab") InvalidType: 'b"ab"' is not a type!
method_id("f()", output_type=b"ab") InvalidType: 'b"ab"' is not a type!
extract32(b, 0, output_type=b"ab") InvalidType: 'b"ab"' is not a type!
In all of them the argument is resolved as a type before the expected-type name is ever formatted, so __str__ is never called (I confirmed this with the same tracing as in the IntegerT thread — zero renders). The only other use, TYPE_T.any().compare_type(...) in _signatures.py, does not stringify either. So there is nothing to write a test against; the name was dead code, and the CompilerPanic now documents that invariant.
On using the underlying object's name (event, interface, ...): that is not available here. .any() is a classmethod and hands _GenericTypeAcceptor the class, not a TYPE_T instance, so there is no typedef to read a name from at that point. If a code path ever does need to display it, the natural fix would be an instance-level name on TYPE_T rather than a class-level _generic_id — but today nothing needs it.
| """ | ||
|
|
||
| typeclass = "integer" | ||
| _generic_id = "integer" |
There was a problem hiding this comment.
I'm not sure this is reachable, since the .any() does not go through a GenericTypeAcceptator
Please look into it
There was a problem hiding this comment.
I looked into it — IntegerT is reachable, so I kept it. .any() is _GenericTypeAcceptor(cls) (base.py), and IntegerT.any() has five call sites.
To be sure the string actually comes from this path rather than somewhere else, I traced _GenericTypeAcceptor.__str__ and recorded every render:
x[b"ab"] TypeMismatch: Expected integer but literal can only be cast as Bytes[2].
rendered via __str__: [(IntegerT, "integer")]
shift(1, b"ab") TypeMismatch: Expected integer but literal can only be cast as Bytes[2].
rendered via __str__: [(IntegerT, "integer")]
Two independent paths (validate_expected_type(node, IntegerT.any()) in subscriptable.py for index validation, and the shift builtin). Without _generic_id these hit the CompilerPanic. The index case is covered by test_index_type_mismatch_message_uses_readable_type_names.
Note that IntegerT.any() inside a tuple of expected types behaves differently — uint2str(b"ab") expands to Expected one of uint8, uint16, ..., which never reaches __str__. So only the standalone uses matter here.
| # user-facing name for parametric types whose `_id` is an instance | ||
| # property (e.g. `bytesM`, `integer`), so it has no value at the class | ||
| # level. see `_GenericTypeAcceptor.__str__`. | ||
| _generic_id: str = None # type: ignore |
There was a problem hiding this comment.
Nit:
If it doesn't have the right type, I'm sure I'll get confused at some point, let's instead:
| _generic_id: str = None # type: ignore | |
| _generic_id: Optional[str] = None |
There was a problem hiding this comment.
Done, applied as suggested: _generic_id: Optional[str] = None, and the # type: ignore is gone with it. mypy -p vyper is clean.
| # error messages, instead of the internal Python class repr. Prefer the | ||
| # class-level `_id`; parametric types (e.g. `IntegerT`, `BytesM_T`) | ||
| # define `_id` as a property, so it has no value at the class level that | ||
| # `.any()` hands us -- fall back to their `_generic_id`. |
There was a problem hiding this comment.
I don't think this needs a comment:
__str__ is for user-facing stuff
The rest just paraphrases the body
Also it seems incorrect:
IntegerT and BytesM_T are not parametric (uint256, bytes4)
(well as python values they are, but then so are String et al)
There was a problem hiding this comment.
Right on both counts — comment removed, __str__ is now just the three lines of body.
And thanks for catching the inaccuracy: "parametric" was the wrong axis (String is parameterized too). The actual distinction is constructor name vs fully applied name in _id; I have written that up in your other thread and corrected the remaining comment on the _generic_id declaration.
| def __str__(self): | ||
| # User-facing type name (e.g. `String`, `Bytes`, `DynArray`) used in | ||
| # error messages, instead of the internal Python class repr. `_id` is a | ||
| # plain class attribute on most types, but a property on parametric | ||
| # types (e.g. `IntegerT`, `BytesM_T`), so fall back to `typeclass` | ||
| # (mapped to its user-facing spelling), then the class name, keeping | ||
| # this total (never returns a non-string). | ||
| name = getattr(self.type_, "_id", None) | ||
| if not isinstance(name, str): | ||
| typeclass = getattr(self.type_, "typeclass", None) | ||
| name = _TYPECLASS_DISPLAY_NAMES.get(typeclass, typeclass) | ||
| if not isinstance(name, str): | ||
| name = self.type_.__name__ | ||
| return name |
There was a problem hiding this comment.
Why does _id on String (et al) work ?
Since if anything String is more parametrized than IntegerT
Address review: - remove `TYPE_T._generic_id`: every `TYPE_T.any()` call site rejects a non-type argument with `InvalidType` before any type name is rendered, so the name was dead code. `CompilerPanic` now covers that case. - type `_generic_id` as `Optional[str]` instead of `str = None`. - drop the comments on `__str__` and the tests; fix the description of `_generic_id`, which is about `_id` naming the fully applied type rather than the type constructor, not about parametricity.
Fixes #4955.
What I did
_GenericTypeAcceptor(used to represent the set of types a builtin accepts) rendered as its internal Python class repr in error messages. For example,len()on a wrong type or a module produced:Now it produces:
How
The type-mismatch message renders acceptors via
str(...)(analysis/utils.py), which fell back to__repr__. I added a__str__that resolves the user-facing type name —_idwhen it's a plain class string (String/Bytes/DynArray), falling back totypeclassfor parametric types whose_idis a property (e.g.IntegerT→integer), then the class name — so it is always a string.__repr__is left as the unambiguous debug form (which also keeps it total, so e.g.TYPE_T.any()doesn't raise).Tests
Added two regression tests to
tests/functional/syntax/test_len.py: one asserting alen()type-mismatch message showsString/Bytes/DynArray(and noGenericTypeAcceptor), and one for thetypeclassfallback branch (indexing with the wrong type →integer). Both fail on the current code and pass with this change;black/isort/flake8are clean.