Skip to content

Run mypy on Lib/test/libregrtest in CI #109382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from

Conversation

AlexWaygood
Copy link
Member

@vstinner: this is essentially the minimum set of changes that are required to get mypy to pass when run on libregrtest. Let me know what you think!

I think it might be good to post on Discourse and check that everybody's okay with this idea, before we go through with it. This would be the first time we'd be running mypy on a project inside the Lib/ directory; all existing mypy-checked projects live inside Tools/.

Copy link
Member

@vstinner vstinner left a comment

Choose a reason for hiding this comment

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

Most libregrtest/ changes LGTM, but I'm not sure about coding style changes. Can you please create a PR just for this change, and then update this PR to just add the infra to run mypy?

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Sep 13, 2023

Most libregrtest/ changes LGTM, but I'm not sure about coding style changes.

Which coding style changes do you dislike? I tried hard not to make any changes in this PR unless they were strictly required to make the mypy check pass (which is why I think it's good to introduce mypy to CI in the same PR: it validates that the changes in this PR are correct, at least from mypy's perspective). If you highlight the changes you're not sure about, hopefully I can explain why they were necessary to make mypy happy ;-)

@vstinner
Copy link
Member

I don't get how replacing self.workers with workers is needed by mypy for example.

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Sep 13, 2023

I don't get how replacing self.workers with workers is needed by mypy for example.

Good point, those changes aren't needed anymore. I think I needed them in an earlier version of this PR, but mypy now seems happy without them :-)

I've reverted those, thanks!

Copy link
Member

@vstinner vstinner left a comment

Choose a reason for hiding this comment

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

Sorry, this change is too big to be reviewed. Please extract non-controversial changes: remove .github/workflows/mypy.yml changes and the lines where I added comments :-) I mean, please create a new PR with the most obvious non-controversial type hint changes.

@@ -1,4 +1,5 @@
import sys
import xml.etree.ElementTree as ET
Copy link
Member

Choose a reason for hiding this comment

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

Please don't do that. I want to minimize imports at startup, to minimize how many modules are imported when a test is run. Maybe even add a comment to explain why the import is not done at top level, that it's a deliberate choice.

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay. The reason why I did this was to accurately add type hints for the TestResults.testsuite_xml attribute on line 35. The current type hints for that attribute are incorrect, and caused several mypy errors.

A possible workaround is to do the top-level import inside an if TYPE_CHECKING block.

Copy link
Member Author

Choose a reason for hiding this comment

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

A possible workaround is to do the top-level import inside an if TYPE_CHECKING block.

I made that change in 581ef0d; let me know what you think!

@@ -184,6 +187,11 @@ def _runtest_env_changed_exc(result: TestResult, runtests: RunTests,
result.state = State.PASSED


class PrintWarningsType(Protocol):
orig_stderr: io.TextIOWrapper
def __call__(self, msg: str) -> None: ...
Copy link
Member

Choose a reason for hiding this comment

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

If possible, I would prefer avoiding to have to add glue on top of code just to please mypy. If print_warning() API is too weird for mypy, maybe rewrite it with such class directly in test.support, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

If print_warning() API is too weird for mypy, maybe rewrite it with such class directly in test.support, no?

Unfortunately not. Mypy doesn't know that the test.support module exists: when it comes to the stdlib, it only knows about the modules we include stubs for in typeshed. Historically, we haven't included stubs for any modules in the Lib/test/ directory, as these aren't available on all builds of Python, and are generally only meant to be used by CPython core devs like us. As a result, everything that is imported from test.support is inferred by mypy as being of type Any.

However, that also means that we don't need this protocol here: I only added it to give mypy a little more information, and make the code slightly more type safe. Since you don't like it, I'll remove it!

Copy link
Member

Choose a reason for hiding this comment

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

Mypy doesn't know that the test.support module exists

Can't we make mypy aware of test.support, instead of having to put ducktape to workaround these limitations?

Copy link
Member Author

Choose a reason for hiding this comment

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

Can't we make mypy aware of test.support

This is somewhat tricky due to the fact that regrtest is in the Lib/test/ directory. Mypy hates being run on stuff inside the Lib/ directory, as it thinks that everything inside the Lib/ directory is "shadowing the stdlib". The only way we're managing to get mypy happy with being run on libregrtest is by cd-ing into Lib/test/, and then running mypy with very specific options so that it doesn't look anywhere else in the Lib/ directory, it only looks in Lib/test/libregrtest.

There would be some ways of making mypy aware of test.support, but I'm not sure I like any of them:

  1. We could write a custom script that copies libregrtest and test.support into a temporary directory, then runs mypy on them together. Why I don't like this: it's easier for users if they can run mypy just by invoking mypy on the command line. Having to run mypy via a wrapper script is a bad user experience, and it's more code for us to maintain as well.
  2. We could add stubs for test.support in typeshed, so that mypy sees test.support as "just another part of the stdlib". Why I don't like this: lots of Python users can't access test.support on their installed versions of Python, but if we add stubs for test.support in typeshed, mypy will never complain if users import test.support in their own code. Also, it will be a lot of work for us typeshed maintainers to maintain stubs for test.support, since the test.support module tends to change its API a lot more frequently than other things in the Lib/ directory.
  3. We could add stubs for test.support inside the libregrtest directory itself. Why I don't like this: this just feels like it would be really ugly, and really hard to maintain. The stubs would go out of date really quickly, if we weren't careful.

Copy link
Member

Choose a reason for hiding this comment

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

We could write a custom script that copies libregrtest and test.support into a temporary directory

That sounds like a good idea to me.

I would prefer that mypy users need workaround, than libregrtest have to write complicated workarounds in the working code just to please mypy.

@@ -195,7 +203,7 @@ def _runtest(result: TestResult, runtests: RunTests) -> None:
timeout is not None and threading_helper.can_start_thread
)
if use_timeout:
faulthandler.dump_traceback_later(timeout, exit=True)
faulthandler.dump_traceback_later(cast(float, timeout), exit=True)
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand this cast. timeout is a float is use_timeout is True. Why is a cast needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this code is type-safe. The issue is that mypy only understands a limited set of type narrowing operations, and the way timeout has been narrowed to be a float here isn't one of them.

Mypy understands this kind of logic:

x: float | None
if x is not None:
    # OK, mypy understands `f` has to be a float here

But this kind of thing is too complex for mypy:

x: float | None
x_is_None = x is None
if not x_is_None:
    # mypy will not understand that x has been safely narrowed to `None` here

Another way we could make mypy happy would be to add an assert, like this -- but it feels redundant to me, since the type of timeout has already been narrowed to be a float in the lines immediately preceding this:

Suggested change
faulthandler.dump_traceback_later(cast(float, timeout), exit=True)
assert timeout is not None
faulthandler.dump_traceback_later(timeout, exit=True)

@@ -67,7 +68,8 @@ def worker_process(worker_json: StrJSON) -> NoReturn:
runtests = RunTests.from_json(worker_json)
test_name = runtests.tests[0]
match_tests: FilterTuple | None = runtests.match_tests
json_file: JsonFile = runtests.json_file
json_file = runtests.json_file
assert isinstance(json_file, JsonFile)
Copy link
Member

Choose a reason for hiding this comment

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

I dislike assert, it's removed by python -O. Why is this assertion needed? The attribute is declared as json_file: JsonFile | None. Is it to remove the None case?

If mypy doesn't understand the logic, maybe a developer can have the same doubt. Maybe add a regular if json_file is None: raise ....

Or maybe, RunTests can have a sub-type which adds the json_file attribute: the class used by create_worker_process() / worker_process(). Would it please mypy?

Copy link
Member Author

Choose a reason for hiding this comment

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

Why is this assertion needed? The attribute is declared as json_file: JsonFile | None. Is it to remove the None case?

Yes, it's to remove the None case.

If mypy doesn't understand the logic, maybe a developer can have the same doubt. Maybe add a regular if json_file is None: raise ....

Sure. Should I do the same for all asserts being proposed in this PR?

Or maybe, RunTests can have a sub-type which adds the json_file attribute: the class used by create_worker_process() / worker_process(). Would it please mypy?

Yes, that might also make mypy happier. Hard to tell without seeing a PR and running mypy on it, though :-)

@AlexWaygood
Copy link
Member Author

please create a new PR with the most obvious non-controversial type hint changes.

Okay, I've had a go at doing that in #109405

@vstinner
Copy link
Member

Can you rebase your PR on the main branch?

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Sep 15, 2023

I'll close this for now, and continue to break it up into smaller PRs so that it's obvious what I'm doing at each stage 👍

@AlexWaygood AlexWaygood deleted the regrtest-mypy-2 branch September 15, 2023 13:27
@AlexWaygood AlexWaygood restored the regrtest-mypy-2 branch September 15, 2023 13:27
@vstinner
Copy link
Member

Nice! I like this idea :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants