Skip to content

[ty] Infer the Python version from the environment if feasible #18057

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

Merged
merged 16 commits into from
May 30, 2025

Conversation

zanieb
Copy link
Member

@zanieb zanieb commented May 12, 2025

Posting the draft to preview the change here and decide if it's fine or if we need to do a more invasive refactor.

Before falling back to the default Python version, use the Python version in the target Python environment.

Only supports reading the version from the pyvenv.cfg right now. We can sniff target versions from the site packages folder name on Unix too? A bit messy though.

@zanieb zanieb added the ty Multi-file analysis & type inference label May 12, 2025
Copy link
Contributor

github-actions bot commented May 12, 2025

mypy_primer results

Changes were detected when running on open source projects
pyinstrument (https://github.com/joerick/pyinstrument)
- error[unresolved-attribute] pyinstrument/vendor/decorator.py:57:34: Type `<module 'inspect'>` has no attribute `getargspec`
- Found 89 diagnostics
+ Found 88 diagnostics

aioredis (https://github.com/aio-libs/aioredis)
- error[unresolved-import] aioredis/connection.py:11:6: Cannot resolve imported module `distutils.version`
- error[duplicate-base] aioredis/exceptions.py:14:7: Duplicate base class `TimeoutError`
- Found 28 diagnostics
+ Found 26 diagnostics

graphql-core (https://github.com/graphql-python/graphql-core)
+ warning[unused-ignore-comment] tests/pyutils/test_is_awaitable.py:88:29: Unused blanket `type: ignore` directive
- Found 407 diagnostics
+ Found 408 diagnostics

stone (https://github.com/dropbox/stone)
+ warning[unused-ignore-comment] stone/frontend/ir_generator.py:9:49: Unused blanket `type: ignore` directive
- Found 173 diagnostics
+ Found 174 diagnostics

vision (https://github.com/pytorch/vision)
- error[unresolved-import] setup.py:1:8: Cannot resolve imported module `distutils.command.clean`
- error[unresolved-import] setup.py:2:8: Cannot resolve imported module `distutils.spawn`
+ warning[possibly-unbound-attribute] torchvision/datasets/video_utils.py:280:12: Attribute `is_integer` on type `int | float` is possibly unbound
- Found 1546 diagnostics
+ Found 1545 diagnostics

bandersnatch (https://github.com/pypa/bandersnatch)
+ error[unresolved-attribute] src/bandersnatch/mirror.py:58:42: Type `<module 'datetime'>` has no attribute `UTC`
+ error[unresolved-import] src/bandersnatch/simple.py:4:24: Module `enum` has no member `StrEnum`
+ error[not-iterable] src/bandersnatch/simple.py:66:49: Object of type `<class 'SimpleDigest'>` is not iterable
+ error[unresolved-attribute] src/bandersnatch/tests/plugins/test_storage_plugins.py:116:25: Type `<module 'datetime'>` has no attribute `UTC`
+ error[unresolved-attribute] src/bandersnatch/tests/plugins/test_storage_plugins.py:123:28: Type `<module 'datetime'>` has no attribute `UTC`
+ error[not-iterable] src/bandersnatch/tests/test_simple.py:52:59: Object of type `<class 'SimpleDigest'>` is not iterable
+ error[unresolved-attribute] src/bandersnatch_storage_plugins/filesystem.py:286:70: Type `<module 'datetime'>` has no attribute `UTC`
- error[unresolved-import] src/bandersnatch_storage_plugins/s3.py:24:10: Cannot resolve imported module `s3path.accessor`
+ error[unresolved-attribute] src/bandersnatch_storage_plugins/s3.py:437:52: Type `<module 'datetime'>` has no attribute `UTC`
+ error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:989:56: Type `<module 'datetime'>` has no attribute `UTC`
- Found 124 diagnostics
+ Found 132 diagnostics

rich (https://github.com/Textualize/rich)
+ error[unresolved-attribute] tests/test_traceback.py:371:9: Type `Exception` has no attribute `add_note`
+ error[unresolved-attribute] tests/test_traceback.py:372:9: Type `Exception` has no attribute `add_note`
- Found 391 diagnostics
+ Found 393 diagnostics

psycopg (https://github.com/psycopg/psycopg)
- error[unresolved-import] psycopg_c/build_backend/psycopg_build_ext.py:13:6: Cannot resolve imported module `distutils`
- error[unresolved-import] psycopg_c/build_backend/psycopg_build_ext.py:14:6: Cannot resolve imported module `distutils.command.build_ext`
- Found 1114 diagnostics
+ Found 1112 diagnostics

paasta (https://github.com/yelp/paasta)
- error[unresolved-import] paasta_tools/run-paasta-api-in-dev-mode.py:3:6: Cannot resolve imported module `distutils.dir_util`
- error[invalid-argument-type] paasta_tools/utils.py:2839:35: Argument to function `split` is incorrect: Expected `str | _ShlexInstream`, found `(str & ~list[Unknown]) | (list[Unknown] & ~list[Unknown])`
+ error[no-matching-overload] paasta_tools/utils.py:2839:23: No overload of function `split` matches arguments
- Found 942 diagnostics
+ Found 941 diagnostics

comtypes (https://github.com/enthought/comtypes)
- error[unresolved-attribute] comtypes/test/__init__.py:137:12: Type `<module 'unittest'>` has no attribute `makeSuite`
- error[unresolved-attribute] comtypes/test/__init__.py:207:15: Type `<module 'unittest'>` has no attribute `makeSuite`
- error[unresolved-import] comtypes/test/setup.py:3:6: Cannot resolve imported module `distutils.core`
- Found 598 diagnostics
+ Found 595 diagnostics

static-frame (https://github.com/static-frame/static-frame)
+ error[unresolved-attribute] static_frame/core/db_util.py:397:18: Type `<module 'typing'>` has no attribute `Self`
+ error[unresolved-attribute] static_frame/core/db_util.py:409:14: Type `<module 'typing'>` has no attribute `Self`
+ error[invalid-syntax] static_frame/test/unit_forward/test_type_clinic.py:16:71: Cannot use star expression in index on Python 3.10 (syntax was added in Python 3.11)
+ error[invalid-syntax] static_frame/test/unit_forward/test_type_clinic.py:23:74: Cannot use star expression in index on Python 3.10 (syntax was added in Python 3.11)
+ error[invalid-syntax] static_frame/test/unit_forward/test_type_clinic.py:29:55: Cannot use star expression in index on Python 3.10 (syntax was added in Python 3.11)
+ error[invalid-syntax] static_frame/test/unit_forward/test_type_clinic.py:29:91: Cannot use star expression in index on Python 3.10 (syntax was added in Python 3.11)
- Found 2065 diagnostics
+ Found 2071 diagnostics

meson (https://github.com/mesonbuild/meson)
+ warning[unused-ignore-comment] mesonbuild/scripts/python_info.py:5:1: Unused blanket `type: ignore` directive
- Found 1387 diagnostics
+ Found 1388 diagnostics

aiohttp (https://github.com/aio-libs/aiohttp)
+ error[unknown-argument] aiohttp/client_reqrep.py:1386:59: Argument `eager_start` does not match any known parameter of bound method `__init__`
+ error[unknown-argument] aiohttp/client_ws.py:149:55: Argument `eager_start` does not match any known parameter of bound method `__init__`
- error[invalid-argument-type] aiohttp/compression_utils.py:198:25: Argument to function `len` is incorrect: Expected `Sized`, found `Buffer`
- error[invalid-argument-type] aiohttp/compression_utils.py:239:21: Argument to function `len` is incorrect: Expected `Sized`, found `Buffer`
+ error[unknown-argument] aiohttp/connector.py:997:64: Argument `eager_start` does not match any known parameter of bound method `__init__`
+ error[unknown-argument] aiohttp/web_protocol.py:379:58: Argument `eager_start` does not match any known parameter of bound method `__init__`
+ error[unknown-argument] aiohttp/web_protocol.py:624:58: Argument `eager_start` does not match any known parameter of bound method `__init__`
+ error[unknown-argument] aiohttp/web_ws.py:171:55: Argument `eager_start` does not match any known parameter of bound method `__init__`
- Found 173 diagnostics
+ Found 177 diagnostics

cloud-init (https://github.com/canonical/cloud-init)
- error[unresolved-import] cloudinit/distros/netbsd.py:15:12: Cannot resolve imported module `crypt`
- error[unresolved-import] cloudinit/sources/DataSourceAzure.py:52:12: Cannot resolve imported module `crypt`
+ error[unresolved-attribute] tests/unittests/helpers.py:609:21: Type `PackageMetadata` has no attribute `get`
+ warning[possibly-unbound-attribute] tests/unittests/sources/azure/test_imds.py:755:26: Attribute `is_integer` on type `int | float` is possibly unbound
+ warning[possibly-unbound-attribute] tests/unittests/sources/azure/test_imds.py:822:26: Attribute `is_integer` on type `int | float` is possibly unbound
+ warning[possibly-unbound-attribute] tests/unittests/sources/azure/test_imds.py:906:26: Attribute `is_integer` on type `int | float` is possibly unbound
- Found 705 diagnostics
+ Found 707 diagnostics

streamlit (https://github.com/streamlit/streamlit)
- error[missing-argument] scripts/run_in_subdirectory.py:35:9: No argument provided for required parameter `other` of bound method `relative_to`
- Found 3270 diagnostics
+ Found 3269 diagnostics

sympy (https://github.com/sympy/sympy)
+ error[unresolved-attribute] sympy/core/numbers.py:3676:20: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1040:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1041:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1043:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1044:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1046:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1047:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1050:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_arit.py:1073:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/core/tests/test_assumptions.py:1227:12: Type `int` has no attribute `is_integer`
+ error[unresolved-attribute] sympy/functions/elementary/exponential.py:307:20: Type `int` has no attribute `is_integer`
- warning[unused-ignore-comment] sympy/polys/puiseux.py:56:32: Unused blanket `type: ignore` directive
+ error[unresolved-import] sympy/polys/puiseux.py:33:29: Module `typing` has no member `Unpack`
+ error[invalid-assignment] sympy/polys/tests/test_puiseux.py:31:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_puiseux.py:45:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_puiseux.py:199:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:245:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:258:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:336:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:585:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:598:5: Not enough values to unpack: Expected 3
+ error[invalid-assignment] sympy/polys/tests/test_ring_series.py:688:5: Not enough values to unpack: Expected 3
- Found 18555 diagnostics
+ Found 18575 diagnostics

materialize (https://github.com/MaterializeInc/materialize)
+ error[unresolved-import] ci/cleanup/aws.py:10:22: Module `datetime` has no member `UTC`
- error[unresolved-import] ci/deploy/pypi.py:10:8: Cannot resolve imported module `distutils.core`
+ error[invalid-return-type] ci/deploy/pypi.py:54:12: Return type does not match returned value: expected `tuple[str, str]`, found `tuple[str | None, str | None]`
+ error[unresolved-attribute] ci/load/periodic.py:34:34: Type `<module 'datetime'>` has no attribute `UTC`
- error[unresolved-import] misc/dbt-materialize/setup.py:18:6: Cannot resolve imported module `distutils.core`
+ error[invalid-syntax] test/race-condition/mzcompose.py:489:113: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12)
+ error[invalid-syntax] test/race-condition/mzcompose.py:513:126: Cannot reuse outer quote character in f-strings on Python 3.10 (syntax was added in Python 3.12)
- Found 3216 diagnostics
+ Found 3219 diagnostics

@zanieb zanieb force-pushed the zb/python-env-version branch from 9632346 to 72ff26a Compare May 13, 2025 00:32
Copy link
Contributor

github-actions bot commented May 13, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@zanieb zanieb force-pushed the zb/python-env-version branch from 72ff26a to 79e2d99 Compare May 13, 2025 00:44
@zanieb
Copy link
Member Author

zanieb commented May 13, 2025

A second opinion seems helpful. Not in a rush to merge before the announcement though.

@zanieb zanieb marked this pull request as ready for review May 13, 2025 04:31
Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

We should do some more testing for how this interacts in --watch mode, and, ideally, at some tests to file_watching.rs.

Two cases that I'm not sure if they work as expected:

  • Changing the settings from having a python-version to not having one (in which case the fallback behavior should apply)
  • Changing the python version in the venv. I don't think ty would pick this up and it would keep running with the wrong version

@sharkdp sharkdp removed their request for review May 13, 2025 11:57
@zanieb
Copy link
Member Author

zanieb commented May 13, 2025

Thanks! Those are good callouts. I'll follow up on this when I have time, otherwise, feel free.

@carljm carljm removed their request for review May 22, 2025 22:04
@carljm
Copy link
Contributor

carljm commented May 22, 2025

This would be great to have (especially since I accidentally said at the typing summit that we already had it 😆 )

@AlexWaygood
Copy link
Member

I'll try to pick this up this week

@AlexWaygood AlexWaygood self-assigned this May 27, 2025
@MichaReiser
Copy link
Member

IMO, not sure if it's worth prioritizing this over other typing features.

@AlexWaygood
Copy link
Member

AlexWaygood commented May 27, 2025

I disagree:

  • I think it's causing a fair amount of user confusion that we don't do this already (people expect us to do it)
  • I think it's a logical change, and I don't think it's too much work to implement it
  • It's a feature already supported by rival type checkers that are not written in Python
  • As Carl says, he mistakenly announced that we already have the feature at the typing summit 😆

The better we do at inferring the Python version from the user's environment, the less likely it is we have to fallback to an arbitrary default Python version, which is always going to lead to false positives and false negatives for some users. Improving usability in this area feels pretty high priority to me.

@carljm carljm marked this pull request as draft May 27, 2025 17:44
@AlexWaygood AlexWaygood force-pushed the zb/python-env-version branch 2 times, most recently from 4a3c360 to d7b6fdb Compare May 28, 2025 22:32
@AlexWaygood
Copy link
Member

AlexWaygood commented May 29, 2025

Here's a screen recording that demonstrates that we correctly update the inferred Python version in --watch mode if a new virtual environment is added, modified or removed again:

Screen.Recording.2025-05-29.at.18.55.55.mov

I checked, and we also get the correct behaviour if you delete a pyproject.toml file that contains a project.requires-python field: we switch to the version specified in the virtual environment's pyvenv.cfg file. Adding it back causes us to switch back to the version specified in the pyproject.toml file.

@AlexWaygood
Copy link
Member

Okay, I think this is ready for re-review now! In terms of tests, I added two CLI tests and recorded the video in #18057 (comment) to demonstrate that the file-watching behaviour is working.

We have fairly comprehensive tests already for pyvenv.cfg parsing, so I think we're okay on that front. I feel like I should be adding more tests of some kind somewhere, but I'm not sure how... I tried adding a file-watching test, but I couldn't get it to work and I wasn't fully sure I understood the setup in file_watching.rs.

@AlexWaygood AlexWaygood marked this pull request as ready for review May 29, 2025 19:38
@AlexWaygood AlexWaygood requested a review from MichaReiser May 29, 2025 19:38
@AlexWaygood
Copy link
Member

(Cc. @zanieb -- would love a review from you of the changes I've pushed here 😄)

@AlexWaygood
Copy link
Member

Ah, the primer report is interesting. I guess an unanticipated consequence of this change is that we're now picking up the Python version from the virtual environments mypy_primer creates on the fly if primer is being run on a project that doesn't specify project.requires-python in its pyproject.toml file.

Comment on lines 385 to 395
/// A parser for `pyvenv.cfg` files: metadata files for virtual environments.
///
/// Note that a `pyvenv.cfg` file *looks* like a `.ini` file, but actually isn't valid `.ini` syntax!
///
/// See also: <https://snarky.ca/how-virtual-environments-work/>
#[derive(Debug)]
struct PyvenvCfgParser<'s> {
cursor: Cursor<'s>,
line_number: NonZeroUsize,
data: RawPyvenvCfg<'s>,
}
Copy link
Member

Choose a reason for hiding this comment

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

The switch to a proper parser here is motivated by wanting to capture the TextRange of the Python version in the pyvenv.cfg file so that we can display it in diagnostics. Screenshot of what this looks like on the command line:

image

@MichaReiser
Copy link
Member

t. I feel like I should be adding more tests of some kind somewhere, but I'm not sure how... I tried adding a file-watching test, but I couldn't get it to work and I wasn't fully sure I understood the setup in file_watching.rs

Can you say more about what you need and what you tried?

@AlexWaygood
Copy link
Member

Can you say more about what you need and what you tried?

I wanted to add a test that showed that if you added a virtual environment, ty would switch to using the Python version from that virtual environment if no Python version was specified in any configuration files. I tried something like this:

diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs
index 71c50ff6eb..d8a398f276 100644
--- a/crates/ty/tests/file_watching.rs
+++ b/crates/ty/tests/file_watching.rs
@@ -1170,6 +1170,42 @@ print(sys.last_exc, os.getegid())
     Ok(())
 }
 
+#[test]
+fn change_python_version_due_to_new_venv() -> anyhow::Result<()> {
+    let mut case = setup(|context: &mut SetupContext| {
+        // `sys.last_exc` is a Python 3.12 only feature;
+        // we default to the latest Python version.
+        context.write_project_file("bar.py", "import sys; print(sys.last_exc)")
+    })?;
+
+    let diagnostics = case.db.check();
+
+    assert!(diagnostics.is_empty());
+
+    let venv_dir = case.project_path(".venv").as_std_path().to_path_buf();
+    std::fs::create_dir_all(&venv_dir)?;
+    std::fs::write(venv_dir.join("pyvenv.cfg"), "version = 3.8")?;
+
+    // Register the venv as a search path:
+    // we should now pick up the Python version from the venv.
+    case.update_options(Options {
+        environment: Some(EnvironmentOptions {
+            extra_paths: Some(vec![RelativePathBuf::cli(".venv/site-packages")]),
+            ..EnvironmentOptions::default()
+        }),
+        ..Options::default()
+    })?;
+
+    let diagnostics = case.db.check();
+    assert_eq!(diagnostics.len(), 1);
+    assert_eq!(
+        diagnostics[0].primary_message(),
+        "Type `<module 'sys'>` has no attribute `last_exc`"
+    );
+
+    Ok(())
+}
+

but it did not pass, even though manual testing appears to indicate that file watching is working as intended. (LMK if that's not the case!)

@MichaReiser
Copy link
Member

MichaReiser commented May 30, 2025

Here's a screen recording that demonstrates that we correctly update the inferred Python version in --watch mode if a new virtual environment is added, modified or removed again:

This is so cool!

File watching tests always have the following structure:

  1. setup call: Writes the "before" state
  2. modifications: Write new files, change the environment that ty should observe
  3. Waiting for the file watcher to observe the changes: let changes = case.stop_watch(event_for_file("sub"))
  4. Applying the observed changes case.apply_changes(changes, None);
  5. Assertions that ty correcty picked up the changes

Your code misses the steps 3 and 4.

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

Nice!

@AlexWaygood AlexWaygood force-pushed the zb/python-env-version branch from 21bacec to 84f151a Compare May 30, 2025 20:55
@AlexWaygood AlexWaygood force-pushed the zb/python-env-version branch from 84f151a to 33c6941 Compare May 30, 2025 21:19
@AlexWaygood
Copy link
Member

I'm still struggling to write a file-watching test. I'm probably missing something, but I'm just going to land for now. Happy to follow up on this if there's an easy way to get it working!

@AlexWaygood AlexWaygood enabled auto-merge (squash) May 30, 2025 21:21
@AlexWaygood AlexWaygood merged commit 88866f0 into main May 30, 2025
30 checks passed
@AlexWaygood AlexWaygood deleted the zb/python-env-version branch May 30, 2025 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
ty Multi-file analysis & type inference
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants