Skip to content

Conversation

mhammond
Copy link
Member

pub struct Record {
    bool: bool, // sad mypy
}

cargo test -p uniffi-fixture-keywords-rust works, but:

% mypy target/tmp/uniffi_keywords_rust-xxx/keywords_rust.py
.../keywords_rust.py:1161: error: Variable "keywords_rust.RecordWithTypeNames.bool" is not valid as a type  [valid-type]

This just demonstrates the error but makes no attempt to fix it yet.

@mhammond mhammond requested a review from a team as a code owner May 30, 2025 03:36
@mhammond mhammond requested review from bendk and removed request for a team May 30, 2025 03:36
@mhammond
Copy link
Member Author

mhammond commented May 30, 2025

"Rust and Foreign Language tests" are failing with the mypy errors.

@mhammond mhammond marked this pull request as draft May 30, 2025 03:57
@mhammond mhammond force-pushed the push-wumupxmwkrxt branch 2 times, most recently from 059c2bf to 4625463 Compare June 14, 2025 09:06
@mhammond
Copy link
Member Author

This isn't just builtins:

class foo: # unlikely, but looking at the general case...
    pass

class Test:
    bool: bool
    int: int
    foo: foo
    def __init__(self, bool: bool, foo: foo):
        pass

t = Test(True, foo())

mypy complains:

/tmp/t.py:8: error: Variable "t.Test.bool" is not valid as a type  [valid-type]
/tmp/t.py:8: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
/tmp/t.py:8: error: Variable "t.Test.foo" is not valid as a type  [valid-type]

(not clear why int isn't also an error)

So my initial naive idea of just special-casing builtin types and using a fully-qualified name from the builtins module isn't going to work in general (although may actually be fine in practice?) I can't see this addressed in the mypy docs.

@mhammond
Copy link
Member Author

@crazytonyli @joe-p @akhramov any ideas here? Not a big deal but slightly annoying.

@joe-p
Copy link

joe-p commented Sep 29, 2025

If the __init__ args have type hints then MyPy can infer the types of the instance variables without needing explicit type hints. This is probably the simplest path forward.

from dataclasses import dataclass


class foo: 
    pass

@dataclass(init=False)
class Test: 
    def __init__(self, bool: bool, foo: foo) -> None:
        self.bool = bool
        self.foo = foo


test = Test(bool=True, foo=foo())
foo_val = test.foo

In this case MyPy knows foo_val is of type foo.

If explicit type hints on the instance vars are desired you could use type aliases, but that was added in 3.10. To be clear I'm not sure why this would be required. I've confirmed both mypy and basedpyright are able to infer the types properly with the above solution.

BoolType: TypeAlias = bool
FooType: TypeAlias = foo

@dataclass
class Test:
    bool: BoolType
    foo: FooType

Edit: I realized you could also just not use the TypeAlias type hint and it would still work

BoolType = bool
FooType = foo

@dataclass
class Test:
    bool: BoolType
    foo: FooType

Although I still think the first solution is preferable because it avoid the alias type being shown to the user

@mhammond
Copy link
Member Author

dataclass is interesting - it looks like it will generate __eq__ etc too? We manually implement that, but a builtin one seems even better. #2671 just today touched the same concept in swift - it replaced manual implementations with a builtin one. We should maybe pursue that in another issue? But that makes sense to me for this issue, thanks.

@mhammond
Copy link
Member Author

huh - we also use the field as a placeholder for the field docstring :(

@joe-p
Copy link

joe-p commented Sep 30, 2025

Oh sorry for some reason I thought uniffi already used dataclass. But yeah the fix for this specific issue still works without dataclasses.

huh - we also use the field as a placeholder for the field docstring :(

Looks like we can define them like this according to PEP 258. I just noticed that that PEP is rejected but I have confirmed locally that these style docstrings are picked up by basedpyright and sphinx, so probably safe to assume most tooling supports them.

class Test: 
    def __init__(self, bool: bool, foo: foo) -> None:
        self.bool = bool
        """ This is a boolean field """

        self.foo = foo
        """ This is a foo field """

@mhammond
Copy link
Member Author

huh - line 2 of that file is identical - so we are just repeating the record docstring for every field, which can be seen in the docstring test:

class RecordTest:
    """
    <docstring-record>
"""
    test: int
    """
    <docstring-record>
"""
    
    def __init__(self, *, test:int):
        self.test = test

and the test is checking only the class -

assert RecordTest.__doc__.strip() == "<docstring-record>"
. As written, it seems like those "extra" docstrings aren't ever going to appear in __doc__ and doesn't appear in help(). The basedpyright playground doesn't seem to show anything about docs, although it is upset at our signatures for __str__ etc. The right thing to do for now is probably to just omit those field docs entirely and tackle that in another issue?

@mhammond
Copy link
Member Author

@joe-p what do you think about this?

@joe-p
Copy link

joe-p commented Sep 30, 2025

Oh interesting... yeah for now type inference in __init__ solves the typing problem this PR is addressing and I agree that fixing the docstrings is an orthogonal issue. At the very least we know there is a solution that would fit with __init__ inference and common pyhton tooling. Deeper implications can be explored in other issues/PRs

@mhammond
Copy link
Member Author

This is failing with:

    def __init__(self, *, no_default_string:str, boolean:bool = _DEFAULT, integer:int = _DEFAULT, float_var:float = _DEFAULT, vec:typing.List[bool] = _DEFAULT, opt_vec:typing.Optional[typing.List[bool]] = _DEFAULT, opt_integer:typing.Optional[int] = _DEFAULT, custom_integer:CustomInteger = _DEFAULT, boolean_default:bool = _DEFAULT, string_default:str = _DEFAULT, opt_default:typing.Optional[str] = _DEFAULT, sub:RecordWithImplicitDefaults = _DEFAULT):
        self.no_default_string = no_default_string
...
        if opt_integer is _DEFAULT:
            self.opt_integer = 42
        else:
            self.opt_integer = opt_integer

with error:

target/tmp/uniffi_proc_macro-b83c1fa2d45093c5/proc_macro.py:2810: error: Incompatible types in assignment (expression has type "int | None", variable has type "int") [assignment] pointing at the last line of that snippet

@joe-p
Copy link

joe-p commented Sep 30, 2025

What is _DEFAULT? I think this is a problem with type narrowing, so conditionally checking against None should work

       if opt_integer is None:
            self.opt_integer = 42
        else:
            self.opt_integer = opt_integer

This should also work:

       if opt_integer is None or is _DEFAULT:
            self.opt_integer = 42
        else:
            self.opt_integer = opt_integer

Alternatively if self.opt_integer being defined is an invariant then you could cast self.opt_integer = cast(int, opt_integer), but I think using is None in the conditional is properly the preferable solution for optional types.

@mhammond
Copy link
Member Author

mhammond commented Oct 1, 2025

It's a placeholder - mainly to handle the "default value" case rather than null. We can't unconditionally use literals due to list etc.

But even in the None case it's a little problematic - integer:int = None seems more confusing than the current _DEFAULT, which is awkward but not really confusing. It only makes sense for optional.

@mhammond
Copy link
Member Author

mhammond commented Oct 1, 2025

(I mean I guess we probably could use literals everywhere other than where they cause problems, but that still leaves lists, maps and objects)

@mhammond mhammond changed the title Python mypy errors when record member names match type names Python: improve default values and use dataclasses to keep mypy happy. Oct 1, 2025
@mhammond
Copy link
Member Author

mhammond commented Oct 1, 2025

I mean I guess we probably could use literals everywhere other than where they cause problems

That actually worked out really well and made the generated code for default values much much better.

@mhammond mhammond marked this pull request as ready for review October 1, 2025 03:38
Improved how default values are handled in function signatures etc,
and more canonical use of Python dataclasses for records and enums,
all towards making mypy happier
Copy link
Contributor

@bendk bendk left a comment

Choose a reason for hiding this comment

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

I don't understand all the mypy stuff, but I trust you and joe-p on that. Behavior-wise, I can't see anything that's changed so thumbs up from me.

@mhammond mhammond merged commit dd2200e into mozilla:main Oct 6, 2025
5 checks passed
@mhammond mhammond deleted the push-wumupxmwkrxt branch October 6, 2025 15:20
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