Archipelago
Archipelago copied to clipboard
CI: strict mypy check in github actions
What is this fixing or adding?
mypy_files.txt is a list of files that will fail the CI if mypy finds errors in them
The idea is that we gradually add files and directories to the list as we get them cleaned up. Eventually it can include all core python source files/directories.
World files and directories could be opt-in by the world maintainer. Adding all worlds doesn't need to be a goal.
This will help keep typing issues from creeping back in after they have been cleaned up.
The check that is done by CI is in a bash script .github/mypy_check.sh
- Developers can run this script before committing/pushing to see whether it will pass.
- Windows users should be able to run it with Git Bash.
A few minor typing changes are included here that are needed to finish cleaning these starting files.
How was this tested?
running the .github/mypy_check.sh
script
Ah hmmm, I have a PR open for a ruff.toml
in the witness directory. I suppose I will have a look at mypy to see if I can switch over or have both
I suppose I can have a pyproject.toml
and have directives for both, probably?
There are some decisions here on mypy options that might be worth discussing.
-
--no-warn-unused-ignore
- don't report a problem if there's a# type: ignore
where mypy doesn't see any problem with the code- The reason for this is that different type checkers get different results. If someone uses
# type: ignore
for a different type checker, mypy would complain about it.
- The reason for this is that different type checkers get different results. If someone uses
-
--no-warn-return-any
- don't report a problem if something with theAny
type is returned from a function (where the function is annotated to return something else)-
arguments for using
--no-warn-return-any
:- Generally,
Any
is a way to silence the type-checker, when we can't specify the type, or it's not worth it to specify the type. If we don't use this, thenAny
isn't silencing the type-checker when we might want it to. The workaround for this would usually just be acast
or# type: ignore
orassert isinstance
right before returning.-
assert isinstance
can't be used for some types -cast
doesn't fit in some complex expressions
-
- Generally,
-
arguments against using
--no-warn-return-any
:-
We use a lot of
argparse.Namespace
If we have code likereturn args.the thing
, that's dangerous, because it's not doing anything to make sure we return the correct type. The type checker doesn't warn us because it'sAny
-
The
assert isinstance
would help catch things in unit tests. -
cast
or# type: ignore
could serve as a reminder that humans need to pay more attention to it.
-
-
I'm still kind of on the fence about --no-warn-return-any
@NewSoupVi I'd like you to comment on the --no-warn-return-any
comments here https://github.com/ArchipelagoMW/Archipelago/pull/3107#issuecomment-2041505402
because I noticed it looking at worlds/messenger/subclasses.py
From this line
world = self.parent_region.multiworld.worlds[self.player]
The type checker doesn't know that world
is a MessengerWorld
, so it doesn't know about world.shop_prices
below.
return world.shop_prices[name] + prereq_cost
It's kind of by chance that you don't get a type error rather than Any
, because of the __getattr__
defined in World
https://github.com/ArchipelagoMW/Archipelago/blob/569c37cb8ed14fcd47fcfcfc2f077df1cb168bb0/worlds/AutoWorld.py#L306
If it weren't for that, you'd need to deal with this even with --no-warn-return-any
, but other relevant situations will be similar to this.
The way I would resolve this is with
assert isinstance(world, MessengerWorld)
(importing MessengerWorld
locally in that function, because a global import is likely to move towards circular import issues.)
Do you think situations like this are worth using assert isinstance
and the like, so that we can get more type safety without --no-warn-return-any
There are definitely some justifications for "Any", and I think forcing compliant worlds to use typing.cast
or assert isinstance
all the time would probably be bad.
Here is a situation I ran into:
some_dict: Dict[int, Union[str, List[str]] = {
1: "String"
2: ["List", "of", "strings"]
}
dict[2].append("another string")
-> mypy gives an error for "str doesn't have append"
So I would have to do this:
cast(List[str], dict[2]).append("another string")
But, because "Any" is allowed, I was able to just do this instead:
some_dict: Dict[int, Any]
which was nice.
I can also see arguments against allowing "Any", but I feel like if we make it super hard for worlds to comply with this, it's probably less likely that people are going to jump on board. I feel like I already have a high tolerance for a Python dev for putting cast
s and if isinstance
and if foo is None
everywhere, and if I had to work around Any as well, I'm not sure I would've wanted to continue refactoring
I tried to refactor Witness to be mypy compliant.
It exposed some genuine bugs, which is cool. It also shows some huge structural problems and oddities of my implementation that will now stare me in the face until I fix them, which is great as well.
The only annoying thing is that since core is not mypy compliant, I can't fix two of the errors. I would have to go into core myself and make PRs to make those files mypy compliant.
I think potentially disabling [import-untyped]
and [no-untyped-call]
might be good? I don't see a situation in which a world would be doing those things in a bad way if all of its files pass mypy individually - If their own functions are all passing mypy, then it by definition must be one from an outside package, so it can't be their fault, right?
Edit: I can also think of a case here where for example an ap randomizer wraps and existing randomizerthat is also written in Python (OoT is an example), and in that case I would also think it's unfair to expect "import-untyped" to be complied with
https://github.com/ArchipelagoMW/Archipelago/pull/3112
This particular option isn't about any usage of Any
, it's about whether we're allowed to return Any
from a function that's supposed to return something else.
Even without allowing the return of Any
you'd still be allowed to use it within one function for things like that.
If you remove the --no-warn-return-any
and then check worlds/messenger/subclasses.py
, you'll see it's fine with using Any
inside the function, but it doesn't like you returning Any
from the function.
The purpose of this feature is to stop problems from escaping the function and spreading.
This particular option isn't about any usage of
Any
, it's about whether we're allowed to returnAny
from a function that's supposed to return something else.
Oh, that's my bad (I'm completely new to the world of mypy haha). In that case, I think I might be on board? In the example you gave, I absolutely think the world should be cast to MessengerWorld.
I think potentially disabling
[import-untyped]
and[no-untyped-call]
might be good? I don't see a situation in which a world would be doing those things in a bad way if all of its files pass mypy individually - If their own functions are all passing mypy, then it by definition must be one from an outside package, so it can't be their fault, right?
I think I want to keep [import-untyped]
and [no-untyped-call]
for a few reasons.
- Core is the focus here. The ability for worlds to opt in is just a side-effect feature.
- (If this is forcing you to make typing fixes in core, that's a good thing, because core is the focus of this check.)
- (These kinds of small typing fix PRs usually don't take as long as other PRs.)
- (If this is forcing you to make typing fixes in core, that's a good thing, because core is the focus of this check.)
- These particular diagnostics help with the gradual nature of this endeavor.
- Example: Because
Baseclasses.py
is 1500 lines, that would make it more difficult to add strict checking to it all at once, but with[no-untyped-call]
, other smaller files that import fromBaseclasses.py
will be added to the list and get some checking inBaseclasses.py
before we're able to add the entireBaseclasses.py
to the list. (That's the reason this PR fixes typing in a few files that are not in the list. So this PR, as it is, gets to check more than just those 2 files. If we disable these checks, it would only check those 2 files, and be a lot less useful, and more difficult to spread to the rest of core.)
- Example: Because
Edit: I can also think of a case here where for example an ap randomizer wraps and existing randomizerthat is also written in Python (OoT is an example), and in that case I would also think it's unfair to expect "import-untyped" to be complied with
This is exactly why I said "Adding all worlds doesn't need to be a goal", and should be opt-in. Note that core should not be importing directly anything specific from worlds, so those worlds shouldn't be able to infect the typing of core.
- (Of course we know core does import directly from LTTP, but that's a problem we all know needs to be fixed.)
- (If this is forcing you to make typing fixes in core, that's a good thing, because core is the focus of this check.)
Interesting. I don't know if I'd personally be motivated to go into core to add typing because I can't put my world in otherwise, so I might just, like, run the mypy locally and be happy that it's still returning 2 errors because of core. But it might work on other people, so I can't say it's a bad strategy :D
Something I've noticed while working on cleaning up my world with this, and will undoubtedly be a common question should anyone try to add their own world to this:
for i in range(1, 11):
set_rule(world.get_location(f"Location {i}"), lambda state, x=i: state.has("Item", x, world.player))
This will throw the following mypy error, while currently being the most accepted way of solving this problem.
error: Cannot infer type of lambda [misc]
Unsure of what solutions are available, if any. It seems this error cannot be disabled without disabling all misc errors.
for i in range(1, 11): set_rule(world.get_location(f"Location {i}"), lambda state, x=i: state.has("Item", x, world.player))
This will throw the following mypy error, while currently being the most accepted way of solving this problem.
error: Cannot infer type of lambda [misc]
Unsure of what solutions are available, if any. It seems this error cannot be disabled without disabling all misc errors.
This is an issue reported in the mypy issue tracker: https://github.com/python/mypy/issues/15459
In Zillion, I use functools.partial
to get around this.
This brings up another possible point of discussion for this: We could choose to use a different type checker for this. Generally, mypy has more bugs than pyright. pyright correctly handles the type of this lambda The reason that I implemented this PR with mypy was just because I found it easier to set up. (command line options and choosing which files to analyze - I think pyright requires a configuration file for non-default options, and I don't even know if we can specify which files as cleanly.)
It wasn't as hard as I thought it would be: https://github.com/ArchipelagoMW/Archipelago/pull/3121
We might add mypy to the existing pyright check.