diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 946c814a5d..d7debe12a3 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -144,7 +144,7 @@ If left unspecified, ty will try to detect common project layouts and initialize * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout) -Besides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), +Besides, if a `./python` or `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` or `__init__.pyi` file), it will also be included in the first party search path. **Default value**: `null` diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index d20a9c8add..26b7a1ba4e 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -1752,3 +1752,126 @@ fn default_root_tests_package() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn default_root_python_folder() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("src/foo.py", "foo = 10"), + ("python/bar.py", "bar = 20"), + ( + "python/test_bar.py", + r#" + from foo import foo + from bar import bar + + print(f"{foo} {bar}") + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "); + + Ok(()) +} + +/// If `python/__init__.py` is present, it is considered a package and `python` is not added to search paths. +#[test] +fn default_root_python_package() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("src/foo.py", "foo = 10"), + ("python/__init__.py", ""), + ("python/bar.py", "bar = 20"), + ( + "python/test_bar.py", + r#" + from foo import foo + from bar import bar # expected unresolved import + + print(f"{foo} {bar}") + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `bar` + --> python/test_bar.py:3:6 + | + 2 | from foo import foo + 3 | from bar import bar # expected unresolved import + | ^^^ + 4 | + 5 | print(f"{foo} {bar}") + | + info: Searched in the following paths during module resolution: + info: 1. / (first-party code) + info: 2. /src (first-party code) + info: 3. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} + +/// Similarly, if `python/__init__.pyi` is present, it is considered a package and `python` is not added to search paths. +#[test] +fn default_root_python_package_pyi() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("src/foo.py", "foo = 10"), + ("python/__init__.pyi", ""), + ("python/bar.py", "bar = 20"), + ( + "python/test_bar.py", + r#" + from foo import foo + from bar import bar # expected unresolved import + + print(f"{foo} {bar}") + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-import]: Cannot resolve imported module `bar` + --> python/test_bar.py:3:6 + | + 2 | from foo import foo + 3 | from bar import bar # expected unresolved import + | ^^^ + 4 | + 5 | print(f"{foo} {bar}") + | + info: Searched in the following paths during module resolution: + info: 1. / (first-party code) + info: 2. /src (first-party code) + info: 3. vendored://stdlib (stdlib typeshed stubs vendored by ty) + info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment + info: rule `unresolved-import` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 1ec0bb2edb..d0770248fa 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -259,11 +259,29 @@ impl Options { vec![project_root.to_path_buf()] }; + let python = project_root.join("python"); + if system.is_directory(&python) + && !system.is_file(&python.join("__init__.py")) + && !system.is_file(&python.join("__init__.pyi")) + && !roots.contains(&python) + { + // If a `./python` directory exists, include it as a source root. This is the recommended layout + // for maturin-based rust/python projects [1]. + // + // https://github.com/PyO3/maturin/blob/979fe1db42bb9e58bc150fa6fc45360b377288bf/README.md?plain=1#L88-L99 + tracing::debug!( + "Including `./python` in `environment.root` because a `./python` directory exists" + ); + + roots.push(python); + } + // Considering pytest test discovery conventions, // we also include the `tests` directory if it exists and is not a package. let tests_dir = project_root.join("tests"); if system.is_directory(&tests_dir) && !system.is_file(&tests_dir.join("__init__.py")) + && !system.is_file(&tests_dir.join("__init__.pyi")) && !roots.contains(&tests_dir) { // If the `tests` directory exists and is not a package, include it as a source root. @@ -428,7 +446,7 @@ pub struct EnvironmentOptions { /// * if a `.//` directory exists, include `.` and `./` in the first party search path /// * otherwise, default to `.` (flat layout) /// - /// Besides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), + /// Besides, if a `./python` or `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` or `__init__.pyi` file), /// it will also be included in the first party search path. #[serde(skip_serializing_if = "Option::is_none")] #[option( diff --git a/ty.schema.json b/ty.schema.json index 45cc92b023..3aee321617 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -101,7 +101,7 @@ ] }, "root": { - "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), it will also be included in the first party search path.", + "description": "The root paths of the project, used for finding first-party modules.\n\nAccepts a list of directory paths searched in priority order (first has highest priority).\n\nIf left unspecified, ty will try to detect common project layouts and initialize `root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./python` or `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` or `__init__.pyi` file), it will also be included in the first party search path.", "type": [ "array", "null"