diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 3efd6e541e..b3ad865ee0 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -172,6 +172,9 @@ 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), +it will also be included in the first party search path. + **Default value**: `null` **Type**: `str` diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index 71bc12264c..2d866114ca 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -1029,6 +1029,52 @@ expected `.`, `]` Ok(()) } + #[test] + fn src_root_with_tests() -> anyhow::Result<()> { + let system = TestSystem::default(); + let root = SystemPathBuf::from("/app"); + + // pytest will find `tests/test_foo.py` and realize it is NOT part of a package + // given that there's no `__init__.py` file in the same folder. + // It will then add `tests` to `sys.path` + // in order to import `test_foo.py` as the module `test_foo`. + system + .memory_file_system() + .write_files_all([ + (root.join("src/main.py"), ""), + (root.join("tests/conftest.py"), ""), + (root.join("tests/test_foo.py"), ""), + ]) + .context("Failed to write files")?; + + let metadata = ProjectMetadata::discover(&root, &system)?; + let settings = metadata + .options + .to_program_settings(&root, "my_package", &system); + + assert_eq!( + settings.search_paths.src_roots, + vec![root.clone(), root.join("src"), root.join("tests")] + ); + + // If `tests/__init__.py` is present, it is considered a package and `tests` is not added to `sys.path`. + system + .memory_file_system() + .write_file(root.join("tests/__init__.py"), "") + .context("Failed to write tests/__init__.py")?; + let metadata = ProjectMetadata::discover(&root, &system)?; + let settings = metadata + .options + .to_program_settings(&root, "my_package", &system); + + assert_eq!( + settings.search_paths.src_roots, + vec![root.clone(), root.join("src")] + ); + + Ok(()) + } + #[track_caller] fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) { assert_eq!(error.to_string().replace('\\', "/"), message); diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 82cf45ab8b..46fd5eadd8 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -135,7 +135,7 @@ impl Options { } else { let src = project_root.join("src"); - if system.is_directory(&src) { + let mut roots = if system.is_directory(&src) { // Default to `src` and the project root if `src` exists and the root hasn't been specified. // This corresponds to the `src-layout` tracing::debug!( @@ -154,7 +154,24 @@ impl Options { // Default to a [flat project structure](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). tracing::debug!("Defaulting `src.root` to `.`"); vec![project_root.to_path_buf()] + }; + + // 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")) + && !roots.contains(&tests_dir) + { + // If the `tests` directory exists and is not a package, include it as a source root. + tracing::debug!( + "Including `./tests` in `src.root` because a `./tests` directory exists" + ); + + roots.push(tests_dir); } + + roots }; let (extra_paths, python, typeshed) = self @@ -392,6 +409,9 @@ pub struct SrcOptions { /// * 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) + /// + /// Besides, 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. #[serde(skip_serializing_if = "Option::is_none")] #[option( default = r#"null"#, diff --git a/ty.schema.json b/ty.schema.json index 3d82862815..0aed4e9558 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -859,7 +859,7 @@ "type": "object", "properties": { "root": { - "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.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)", + "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.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.", "type": [ "string", "null"