[ty] Align Python requirement diagnostic tests with project conventions
diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 3a00c82..0883916 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs
@@ -59,9 +59,63 @@ Ok(()) } +/// Same as above, but for the Python platform. #[test] -fn project_python_requirement_mismatch_only_explains_version_sensitive_errors() -> anyhow::Result<()> -{ +fn config_override_python_platform() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.environment] + python-platform = "linux" + "#, + ), + ( + "test.py", + r#" + import sys + from typing_extensions import reveal_type + + reveal_type(sys.platform) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> test.py:5:13 + | + 5 | reveal_type(sys.platform) + | ^^^^^^^^^^^^ `Literal["linux"]` + + Found 1 diagnostic + + ----- stderr ----- + "#); + + assert_cmd_snapshot!(case.command().arg("--python-platform").arg("all"), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> test.py:5:13 + | + 5 | reveal_type(sys.platform) + | ^^^^^^^^^^^^ `LiteralString` + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn python_requirement_mismatch_only_explains_version_sensitive_errors() -> anyhow::Result<()> { let case = CliTest::with_files([ ( "pyproject.toml", @@ -116,34 +170,92 @@ ----- stderr ----- "#); - case.write_file( - "ty.toml", - r#" - [environment] - python-version = "3.12" - "#, - )?; - let configured = case.command().output()?; - assert!(!configured.status.success()); - let stdout = String::from_utf8(configured.stdout)?; - assert!(stdout.contains("--> ty.toml:3:18")); - assert!(stdout.contains("--> pyproject.toml:3:19")); - assert_eq!( - stdout - .matches("does not satisfy the `requires-python`") - .count(), - 1 + Ok(()) +} + +#[test] +fn python_requirement_mismatch_from_configuration() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [project] + requires-python = ">=3.13" + "#, + ), + ( + "ty.toml", + r#" + [environment] + python-version = "3.12" + "#, + ), + ("test.py", "PythonFinalizationError"), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `PythonFinalizationError` used when not defined + --> test.py:1:1 + | + 1 | PythonFinalizationError + | ^^^^^^^^^^^^^^^^^^^^^^^ + info: `PythonFinalizationError` was added as a builtin in Python 3.13 + info: Python 3.12 was assumed when resolving types + --> ty.toml:3:18 + | + 3 | python-version = "3.12" + | ^^^^^^ Python version configuration + info: Python 3.12 does not satisfy the `requires-python` constraint `>=3.13` + --> pyproject.toml:3:19 + | + 3 | requires-python = ">=3.13" + | ^^^^^^^^ Python version requirement + + Found 1 diagnostic + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn python_requirement_mismatch_in_concise_output() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [project] + requires-python = ">=3.13" + "#, + ), + ("test.py", "PythonFinalizationError"), + ])?; + + assert_cmd_snapshot!( + case.command() + .arg("--python-version=3.12") + .arg("--output-format=concise"), + @" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:1: error[unresolved-reference] Name `PythonFinalizationError` used when not defined + Found 1 diagnostic + + ----- stderr ----- + " ); - let concise = case - .command() - .arg("--python-version=3.12") - .arg("--output-format=concise") - .output()?; - assert!(!concise.status.success()); - assert!(!String::from_utf8(concise.stdout)?.contains("does not satisfy")); + Ok(()) +} - let clean = CliTest::with_files([ +#[test] +fn python_requirement_mismatch_without_diagnostics() -> anyhow::Result<()> { + let case = CliTest::with_files([ ( "pyproject.toml", r#" @@ -153,61 +265,12 @@ ), ("test.py", "value = 1"), ])?; - let output = clean.command().arg("--python-version=3.12").output()?; - assert!(output.status.success()); - assert!(!String::from_utf8(output.stdout)?.contains("does not satisfy")); - Ok(()) -} - -/// Same as above, but for the Python platform. -#[test] -fn config_override_python_platform() -> anyhow::Result<()> { - let case = CliTest::with_files([ - ( - "pyproject.toml", - r#" - [tool.ty.environment] - python-platform = "linux" - "#, - ), - ( - "test.py", - r#" - import sys - from typing_extensions import reveal_type - - reveal_type(sys.platform) - "#, - ), - ])?; - - assert_cmd_snapshot!(case.command(), @r#" + assert_cmd_snapshot!(case.command().arg("--python-version=3.12"), @" success: true exit_code: 0 ----- stdout ----- - info[revealed-type]: Revealed type - --> test.py:5:13 - | - 5 | reveal_type(sys.platform) - | ^^^^^^^^^^^^ `Literal["linux"]` - - Found 1 diagnostic - - ----- stderr ----- - "#); - - assert_cmd_snapshot!(case.command().arg("--python-platform").arg("all"), @" - success: true - exit_code: 0 - ----- stdout ----- - info[revealed-type]: Revealed type - --> test.py:5:13 - | - 5 | reveal_type(sys.platform) - | ^^^^^^^^^^^^ `LiteralString` - - Found 1 diagnostic + All checks passed! ----- stderr ----- "); @@ -999,14 +1062,26 @@ ----- stderr ----- "); - let incompatible = case.command().arg("--python-version=3.7").output()?; - assert!(!incompatible.status.success()); - let stdout = String::from_utf8(incompatible.stdout)?; - assert!(stdout.contains("error[invalid-syntax]")); - assert!( - stdout.contains("Python 3.7 does not satisfy the `requires-python` constraint `>=3.8`") - ); - assert!(stdout.contains("--> pyproject.toml:3:19")); + assert_cmd_snapshot!(case.command().arg("--python-version=3.7"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-syntax]: Cannot use `match` statement on Python 3.7 (syntax was added in Python 3.10) + --> test.py:2:1 + | + 2 | match object(): + | ^^^^^ + info: Python 3.7 was assumed when parsing syntax because it was specified on the command line + info: Python 3.7 does not satisfy the `requires-python` constraint `>=3.8` + --> pyproject.toml:3:19 + | + 3 | requires-python = ">=3.8" + | ^^^^^^^ Python version requirement + + Found 1 diagnostic + + ----- stderr ----- + "#); Ok(()) }
diff --git a/crates/ty/tests/cli/scripts.rs b/crates/ty/tests/cli/scripts.rs index b87f538..a717f31 100644 --- a/crates/ty/tests/cli/scripts.rs +++ b/crates/ty/tests/cli/scripts.rs
@@ -144,23 +144,59 @@ ----- stderr ----- "#); - case.write_file( - "explicit.toml", - r#" - [environment] - python-version = "3.11" - "#, - )?; - let configured = case - .command() - .arg("--config-file") - .arg("explicit.toml") - .output()?; - assert!(!configured.status.success()); - let stdout = String::from_utf8(configured.stdout)?; - assert!(stdout.contains("--> explicit.toml:3:18")); - assert!(stdout.contains("--> script.py:3:21")); - assert!(stdout.contains("Python version requirement")); + Ok(()) +} + +#[test] +fn python_requirement_mismatch_from_explicit_configuration() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "script.py", + r#" + # /// script + # requires-python = ">=3.12" + # /// + + PythonFinalizationError + "#, + ), + ( + "explicit.toml", + r#" + [environment] + python-version = "3.11" + "#, + ), + ])?; + + assert_cmd_snapshot!( + case.command().arg("--config-file").arg("explicit.toml"), + @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `PythonFinalizationError` used when not defined + --> script.py:6:1 + | + 6 | PythonFinalizationError + | ^^^^^^^^^^^^^^^^^^^^^^^ + info: `PythonFinalizationError` was added as a builtin in Python 3.13 + info: Python 3.11 was assumed when resolving types + --> explicit.toml:3:18 + | + 3 | python-version = "3.11" + | ^^^^^^ Python version configuration + info: Python 3.11 does not satisfy the `requires-python` constraint `>=3.12` + --> script.py:3:21 + | + 3 | # requires-python = ">=3.12" + | ^^^^^^^^ Python version requirement + + Found 1 diagnostic + + ----- stderr ----- + "# + ); Ok(()) } @@ -182,29 +218,77 @@ "#, )?; - let output = case.command().arg("--python-version=3.12").output()?; - assert!(!output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("error[unresolved-reference]: Name `PythonFinalizationError`")); - assert!(stdout.contains("error[unresolved-reference]: Name `missing`")); - assert!(stdout.contains("info[revealed-type]: Revealed type")); - assert_eq!( - stdout - .matches("does not satisfy the `requires-python`") - .count(), - 1 + assert_cmd_snapshot!(case.command().arg("--python-version=3.12"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `PythonFinalizationError` used when not defined + --> script.py:8:1 + | + 8 | PythonFinalizationError + | ^^^^^^^^^^^^^^^^^^^^^^^ + info: `PythonFinalizationError` was added as a builtin in Python 3.13 + info: Python 3.12 was assumed when resolving types because it was specified on the command line + info: Python 3.12 does not satisfy the `requires-python` constraint `>=3.13` + --> script.py:3:21 + | + 3 | # requires-python = ">=3.13" + | ^^^^^^^^ Python version requirement + + error[unresolved-reference]: Name `missing` used when not defined + --> script.py:9:7 + | + 9 | print(missing) + | ^^^^^^^ + + info[revealed-type]: Revealed type + --> script.py:10:13 + | + 10 | reveal_type(1) + | ^ `Literal[1]` + + Found 3 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + +#[test] +fn python_requirement_mismatch_in_concise_output() -> anyhow::Result<()> { + let case = CliTest::with_file( + "script.py", + r#" + # /// script + # requires-python = ">=3.13" + # /// + + PythonFinalizationError + "#, + )?; + + assert_cmd_snapshot!( + case.command() + .arg("--python-version=3.12") + .arg("--output-format=concise"), + @" + success: false + exit_code: 1 + ----- stdout ----- + script.py:6:1: error[unresolved-reference] Name `PythonFinalizationError` used when not defined + Found 1 diagnostic + + ----- stderr ----- + " ); - assert!(stdout.contains("--> script.py:3:21")); - let concise = case - .command() - .arg("--python-version=3.12") - .arg("--output-format=concise") - .output()?; - assert!(!concise.status.success()); - assert!(!String::from_utf8(concise.stdout)?.contains("does not satisfy")); + Ok(()) +} - let clean = CliTest::with_file( +#[test] +fn python_requirement_mismatch_without_diagnostics() -> anyhow::Result<()> { + let case = CliTest::with_file( "script.py", r#" # /// script @@ -214,9 +298,15 @@ value = 1 "#, )?; - let output = clean.command().arg("--python-version=3.12").output()?; - assert!(output.status.success()); - assert!(!String::from_utf8(output.stdout)?.contains("does not satisfy")); + + assert_cmd_snapshot!(case.command().arg("--python-version=3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); Ok(()) } @@ -666,18 +756,6 @@ "# ); - let concise = case - .command() - .arg("--python-version") - .arg("3.12") - .arg("--python-platform") - .arg("linux") - .arg("--output-format") - .arg("concise") - .output()?; - assert!(concise.status.success()); - assert!(!String::from_utf8(concise.stdout)?.contains("does not satisfy")); - Ok(()) }
diff --git a/crates/ty_project/src/metadata/pyproject.rs b/crates/ty_project/src/metadata/pyproject.rs index 717cbf0..bd0f1c5 100644 --- a/crates/ty_project/src/metadata/pyproject.rs +++ b/crates/ty_project/src/metadata/pyproject.rs
@@ -293,7 +293,34 @@ #[cfg(test)] mod tests { - use super::PackageName; + use pep440_rs::VersionSpecifiers; + use ruff_python_ast::PythonVersion; + + use super::{PackageName, python_version_satisfies_requirement}; + + #[test] + fn python_requirement_compares_minor_versions() -> anyhow::Result<()> { + for (requirement, python_version, expected) in [ + (">=3.13", PythonVersion::PY312, false), + (">=3.13.0b0", PythonVersion::PY312, false), + (">=3.13.0b0", PythonVersion::PY313, true), + (">=3.12,<3.13", PythonVersion::PY312, true), + (">=3.12.5,<3.13", PythonVersion::PY312, true), + (">3.12,<3.13", PythonVersion::PY312, true), + ("==3.12.5", PythonVersion::PY312, true), + (">=3.12,!=3.12.*", PythonVersion::PY312, false), + (">=3.12,<3.13", PythonVersion::PY313, false), + ] { + let requirement = requirement.parse::<VersionSpecifiers>()?; + assert_eq!( + python_version_satisfies_requirement(&requirement, python_version), + expected, + "Python {python_version} and requirement `{requirement}`", + ); + } + + Ok(()) + } #[test] fn normalize() {
diff --git a/crates/ty_project/src/script.rs b/crates/ty_project/src/script.rs index 2948932..ed32ce9 100644 --- a/crates/ty_project/src/script.rs +++ b/crates/ty_project/src/script.rs
@@ -248,44 +248,17 @@ #[cfg(test)] mod tests { - use pep440_rs::VersionSpecifiers; use ruff_db::files::system_path_to_file; use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf}; use ruff_db::testing::assert_function_query_was_not_run; - use ruff_python_ast::PythonVersion; use ty_python_semantic::Db as _; use crate::db::testing::TestDb; - use crate::metadata::pyproject::python_version_satisfies_requirement; use crate::{Db as _, ProjectMetadata}; use super::{Script, script}; #[test] - fn python_requirement_compares_minor_versions() -> anyhow::Result<()> { - for (requirement, python_version, expected) in [ - (">=3.13", PythonVersion::PY312, false), - (">=3.13.0b0", PythonVersion::PY312, false), - (">=3.13.0b0", PythonVersion::PY313, true), - (">=3.12,<3.13", PythonVersion::PY312, true), - (">=3.12.5,<3.13", PythonVersion::PY312, true), - (">3.12,<3.13", PythonVersion::PY312, true), - ("==3.12.5", PythonVersion::PY312, true), - (">=3.12,!=3.12.*", PythonVersion::PY312, false), - (">=3.12,<3.13", PythonVersion::PY313, false), - ] { - let requirement = requirement.parse::<VersionSpecifiers>()?; - assert_eq!( - python_version_satisfies_requirement(&requirement, python_version), - expected, - "Python {python_version} and requirement `{requirement}`", - ); - } - - Ok(()) - } - - #[test] fn ordinary_files_do_not_depend_on_open_files() -> anyhow::Result<()> { let mut db = TestDb::new(ProjectMetadata::new( "test",
diff --git a/crates/ty_server/tests/e2e/configuration.rs b/crates/ty_server/tests/e2e/configuration.rs index 374a7ae..8b1b5c4 100644 --- a/crates/ty_server/tests/e2e/configuration.rs +++ b/crates/ty_server/tests/e2e/configuration.rs
@@ -145,65 +145,176 @@ } #[test] -fn editor_python_version_override_reports_requirement_as_related_information() -> Result<()> { - for (path, content, project_metadata, requirement_path) in [ - ( - "src/project.py", - "PythonFinalizationError\n", - Some("[project]\nrequires-python = \">=3.13\"\n"), - "src/pyproject.toml", - ), - ( - "src/script.py", - "# /// script\n# requires-python = \">=3.13\"\n# ///\n\nPythonFinalizationError\n", - None, - "src/script.py", - ), - ] { - let workspace_root = SystemPath::new("src"); - let file = SystemPath::new(path); - let mut builder = TestServerBuilder::new()? - .enable_diagnostic_related_information(true) - .with_workspace( - workspace_root, - Some(ClientOptions { - workspace: WorkspaceOptions { - configuration: Some( - Map::from_iter([( - "environment".to_string(), - json!({"python-version": "3.12"}), - )]) - .into(), - ), - ..WorkspaceOptions::default() - }, - ..ClientOptions::default() - }), - )? - .with_file(file, content)?; +fn editor_python_version_override_reports_project_requirement_as_related_information() -> Result<()> +{ + let _filter = filter_result_id(); - if let Some(project_metadata) = project_metadata { - builder = builder.with_file("src/pyproject.toml", project_metadata)?; + let workspace_root = SystemPath::new("src"); + let project = SystemPath::new("src/project.py"); + let project_content = "PythonFinalizationError\n"; + let project_metadata = r#"[project] +requires-python = ">=3.13" +"#; + + let mut server = TestServerBuilder::new()? + .enable_diagnostic_related_information(true) + .with_workspace( + workspace_root, + Some(ClientOptions { + workspace: WorkspaceOptions { + configuration: Some( + Map::from_iter([( + "environment".to_string(), + json!({"python-version": "3.12"}), + )]) + .into(), + ), + ..WorkspaceOptions::default() + }, + ..ClientOptions::default() + }), + )? + .with_file(project, project_content)? + .with_file("src/pyproject.toml", project_metadata)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(project, project_content, 1); + let diagnostics = server.document_diagnostic_request(project, None); + + assert_json_snapshot!(diagnostics, @r#" + { + "resultId": "[RESULT_ID]", + "items": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 23 + } + }, + "severity": 1, + "code": "unresolved-reference", + "codeDescription": { + "href": "https://ty.dev/rules#unresolved-reference" + }, + "source": "ty", + "message": "Name `PythonFinalizationError` used when not defined\n\ninfo: `PythonFinalizationError` was added as a builtin in Python 3.13\ninfo: Python 3.12 was assumed when resolving types because it's the version of the selected Python interpreter in your editor", + "relatedInformation": [ + { + "location": { + "uri": "file://<temp_dir>/src/pyproject.toml", + "range": { + "start": { + "line": 1, + "character": 18 + }, + "end": { + "line": 1, + "character": 26 + } + } + }, + "message": "Python 3.12 does not satisfy the `requires-python` constraint `>=3.13`: Python version requirement" + } + ] } - - let mut server = builder.build().wait_until_workspaces_are_initialized(); - server.open_text_document(file, content, 1); - let diagnostics = serde_json::to_value(server.document_diagnostic_request(file, None))?; - assert_eq!(diagnostics["items"].as_array().map(Vec::len), Some(1)); - - let related = &diagnostics["items"][0]["relatedInformation"]; - assert_eq!(related.as_array().map(Vec::len), Some(1)); - assert!(related[0]["message"].as_str().is_some_and(|message| { - message - .contains("Python 3.12 does not satisfy the `requires-python` constraint `>=3.13`") - })); - assert!( - related[0]["location"]["uri"] - .as_str() - .is_some_and(|uri| uri.ends_with(requirement_path)) - ); - assert_eq!(related[0]["location"]["range"]["start"]["line"], 1); + ], + "kind": "full" } + "#); + + Ok(()) +} + +#[test] +fn editor_python_version_override_reports_script_requirement_as_related_information() -> Result<()> +{ + let _filter = filter_result_id(); + + let workspace_root = SystemPath::new("src"); + let script = SystemPath::new("src/script.py"); + let script_content = r#"# /// script +# requires-python = ">=3.13" +# /// + +PythonFinalizationError +"#; + + let mut server = TestServerBuilder::new()? + .enable_diagnostic_related_information(true) + .with_workspace( + workspace_root, + Some(ClientOptions { + workspace: WorkspaceOptions { + configuration: Some( + Map::from_iter([( + "environment".to_string(), + json!({"python-version": "3.12"}), + )]) + .into(), + ), + ..WorkspaceOptions::default() + }, + ..ClientOptions::default() + }), + )? + .with_file(script, script_content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(script, script_content, 1); + let diagnostics = server.document_diagnostic_request(script, None); + + assert_json_snapshot!(diagnostics, @r#" + { + "resultId": "[RESULT_ID]", + "items": [ + { + "range": { + "start": { + "line": 4, + "character": 0 + }, + "end": { + "line": 4, + "character": 23 + } + }, + "severity": 1, + "code": "unresolved-reference", + "codeDescription": { + "href": "https://ty.dev/rules#unresolved-reference" + }, + "source": "ty", + "message": "Name `PythonFinalizationError` used when not defined\n\ninfo: `PythonFinalizationError` was added as a builtin in Python 3.13\ninfo: Python 3.12 was assumed when resolving types because it's the version of the selected Python interpreter in your editor", + "relatedInformation": [ + { + "location": { + "uri": "file://<temp_dir>/src/script.py", + "range": { + "start": { + "line": 1, + "character": 20 + }, + "end": { + "line": 1, + "character": 28 + } + } + }, + "message": "Python 3.12 does not satisfy the `requires-python` constraint `>=3.13`: Python version requirement" + } + ] + } + ], + "kind": "full" + } + "#); Ok(()) }