mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-30 13:51:37 +00:00

## Summary Part of #15383, this PR adds support for overloaded callables. Typing spec: https://typing.python.org/en/latest/spec/overload.html Specifically, it does the following: 1. Update the `FunctionType::signature` method to return signatures from a possibly overloaded callable using a new `FunctionSignature` enum 2. Update `CallableType` to accommodate overloaded callable by updating the inner type to `Box<[Signature]>` 3. Update the relation methods on `CallableType` with logic specific to overloads 4. Update the display of callable type to display a list of signatures enclosed by parenthesis 5. Update `CallableTypeOf` special form to recognize overloaded callable 6. Update subtyping, assignability and fully static check to account for callables (equivalence is planned to be done as a follow-up) For (2), it is required to be done in this PR because otherwise I'd need to add some workaround for `into_callable_type` and I though it would be best to include it in here. For (2), another possible design would be convert `CallableType` in an enum with two variants `CallableType::Single` and `CallableType::Overload` but I decided to go with `Box<[Signature]>` for now to (a) mirror it to be equivalent to `overload` field on `CallableSignature` and (b) to avoid any refactor in this PR. This could be done in a follow-up to better split the two kind of callables. ### Design There were two main candidates on how to represent the overloaded definition: 1. To include it in the existing infrastructure which is what this PR is doing by recognizing all the signatures within the `FunctionType::signature` method 2. To create a new `Overload` type variant <details><summary>For context, this is what I had in mind with the new type variant:</summary> <p> ```rs pub enum Type { FunctionLiteral(FunctionType), Overload(OverloadType), BoundMethod(BoundMethodType), ... } pub struct OverloadType { // FunctionLiteral or BoundMethod overloads: Box<[Type]>, // FunctionLiteral or BoundMethod implementation: Option<Type> } pub struct BoundMethodType { kind: BoundMethodKind, self_instance: Type, } pub enum BoundMethodKind { Function(FunctionType), Overload(OverloadType), } ``` </p> </details> The main reasons to choose (1) are the simplicity in the implementation, reusing the existing infrastructure, avoiding any complications that the new type variant has specifically around the different variants between function and methods which would require the overload type to use `Type` instead. ### Implementation The core logic is how to collect all the overloaded functions. The way this is done in this PR is by recording a **use** on the `Identifier` node that represents the function name in the use-def map. This is then used to fetch the previous symbol using the same name. This way the signatures are going to be propagated from top to bottom (from first overload to the final overload or the implementation) with each function / method. For example: ```py from typing import overload @overload def foo(x: int) -> int: ... @overload def foo(x: str) -> str: ... def foo(x: int | str) -> int | str: return x ``` Here, each definition of `foo` knows about all the signatures that comes before itself. So, the first overload would only see itself, the second would see the first and itself and so on until the implementation or the final overload. This approach required some updates specifically recognizing `Identifier` node to record the function use because it doesn't use `ExprName`. ## Test Plan Update existing test cases which were limited by the overload support and add test cases for the following cases: * Valid overloads as functions, methods, generics, version specific * Invalid overloads as stated in https://typing.python.org/en/latest/spec/overload.html#invalid-overload-definitions (implementation will be done in a follow-up) * Various relation: fully static, subtyping, and assignability (others in a follow-up) ## Ecosystem changes _WIP_ After going through the ecosystem changes (there are a lot!), here's what I've found: We need assignability check between a callable type and a class literal because a lot of builtins are defined as classes in typeshed whose constructor method is overloaded e.g., `map`, `sorted`, `list.sort`, `max`, `min` with the `key` parameter, `collections.abc.defaultdict`, etc. (https://github.com/astral-sh/ruff/issues/17343). This makes up most of the ecosystem diff **roughly 70 diagnostics**. For example: ```py from collections import defaultdict # red-knot: No overload of bound method `__init__` matches arguments [lint:no-matching-overload] defaultdict(int) # red-knot: No overload of bound method `__init__` matches arguments [lint:no-matching-overload] defaultdict(list) class Foo: def __init__(self, x: int): self.x = x # red-knot: No overload of function `__new__` matches arguments [lint:no-matching-overload] map(Foo, ["a", "b", "c"]) ``` Duplicate diagnostics in unpacking (https://github.com/astral-sh/ruff/issues/16514) has **~16 diagnostics**. Support for the `callable` builtin which requires `TypeIs` support. This is **5 diagnostics**. For example: ```py from typing import Any def _(x: Any | None) -> None: if callable(x): # red-knot: `Any | None` # Pyright: `(...) -> object` # mypy: `Any` # pyrefly: `(...) -> object` reveal_type(x) ``` Narrowing on `assert` which has **11 diagnostics**. This is being worked on in https://github.com/astral-sh/ruff/pull/17345. For example: ```py import re match = re.search("", "") assert match match.group() # error: [possibly-unbound-attribute] ``` Others: * `Self`: 2 * Type aliases: 6 * Generics: 3 * Protocols: 13 * Unpacking in comprehension: 1 (https://github.com/astral-sh/ruff/pull/17396) ## Performance Refer to https://github.com/astral-sh/ruff/pull/17366#issuecomment-2814053046.
1168 lines
27 KiB
Rust
1168 lines
27 KiB
Rust
use anyhow::Context;
|
|
use insta::internals::SettingsBindDropGuard;
|
|
use insta_cmd::{assert_cmd_snapshot, get_cargo_bin};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
/// Specifying an option on the CLI should take precedence over the same setting in the
|
|
/// project's configuration. Here, this is tested for the Python version.
|
|
#[test]
|
|
fn config_override_python_version() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.environment]
|
|
python-version = "3.11"
|
|
"#,
|
|
),
|
|
(
|
|
"test.py",
|
|
r#"
|
|
import sys
|
|
|
|
# Access `sys.last_exc` that was only added in Python 3.12
|
|
print(sys.last_exc)
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:unresolved-attribute
|
|
--> <temp_dir>/test.py:5:7
|
|
|
|
|
4 | # Access `sys.last_exc` that was only added in Python 3.12
|
|
5 | print(sys.last_exc)
|
|
| ^^^^^^^^^^^^ Type `<module 'sys'>` has no attribute `last_exc`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
All checks passed!
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Same as above, but for the Python platform.
|
|
#[test]
|
|
fn config_override_python_platform() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.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
|
|
--> <temp_dir>/test.py:5:1
|
|
|
|
|
3 | from typing_extensions import reveal_type
|
|
4 |
|
|
5 | reveal_type(sys.platform)
|
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^ `Literal["linux"]`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
"#);
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--python-platform").arg("all"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
info: revealed-type: Revealed type
|
|
--> <temp_dir>/test.py:5:1
|
|
|
|
|
3 | from typing_extensions import reveal_type
|
|
4 |
|
|
5 | reveal_type(sys.platform)
|
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^ `LiteralString`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Paths specified on the CLI are relative to the current working directory and not the project root.
|
|
///
|
|
/// We test this by adding an extra search path from the CLI to the libs directory when
|
|
/// running the CLI from the child directory (using relative paths).
|
|
///
|
|
/// Project layout:
|
|
/// ```
|
|
/// - libs
|
|
/// |- utils.py
|
|
/// - child
|
|
/// | - test.py
|
|
/// - pyproject.toml
|
|
/// ```
|
|
///
|
|
/// And the command is run in the `child` directory.
|
|
#[test]
|
|
fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.environment]
|
|
python-version = "3.11"
|
|
"#,
|
|
),
|
|
(
|
|
"libs/utils.py",
|
|
r#"
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
"#,
|
|
),
|
|
(
|
|
"child/test.py",
|
|
r#"
|
|
from utils import add
|
|
|
|
stat = add(10, 15)
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
// Make sure that the CLI fails when the `libs` directory is not in the search path.
|
|
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/child/test.py:2:6
|
|
|
|
|
2 | from utils import add
|
|
| ^^^^^ Cannot resolve import `utils`
|
|
3 |
|
|
4 | stat = add(10, 15)
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")).arg("--extra-search-path").arg("../libs"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
All checks passed!
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Paths specified in a configuration file are relative to the project root.
|
|
///
|
|
/// We test this by adding `libs` (as a relative path) to the extra search path in the configuration and run
|
|
/// the CLI from a subdirectory.
|
|
///
|
|
/// Project layout:
|
|
/// ```
|
|
/// - libs
|
|
/// |- utils.py
|
|
/// - child
|
|
/// | - test.py
|
|
/// - pyproject.toml
|
|
/// ```
|
|
#[test]
|
|
fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.environment]
|
|
python-version = "3.11"
|
|
extra-paths = ["libs"]
|
|
"#,
|
|
),
|
|
(
|
|
"libs/utils.py",
|
|
r#"
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
"#,
|
|
),
|
|
(
|
|
"child/test.py",
|
|
r#"
|
|
from utils import add
|
|
|
|
stat = add(10, 15)
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
assert_cmd_snapshot!(case.command().current_dir(case.root().join("child")), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
All checks passed!
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The rule severity can be changed in the configuration file
|
|
#[test]
|
|
fn configuration_rule_severity() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
y = 4 / 0
|
|
|
|
for a in range(0, int(y)):
|
|
x = a
|
|
|
|
print(x) # possibly-unresolved-reference
|
|
"#,
|
|
)?;
|
|
|
|
// Assert that there's a possibly unresolved reference diagnostic
|
|
// and that division-by-zero has a severity of error by default.
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:division-by-zero
|
|
--> <temp_dir>/test.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
warning: lint:possibly-unresolved-reference
|
|
--> <temp_dir>/test.py:7:7
|
|
|
|
|
5 | x = a
|
|
6 |
|
|
7 | print(x) # possibly-unresolved-reference
|
|
| ^ Name `x` used when possibly not defined
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
case.write_file(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.rules]
|
|
division-by-zero = "warn" # demote to warn
|
|
possibly-unresolved-reference = "ignore"
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:division-by-zero
|
|
--> <temp_dir>/test.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The rule severity can be changed using `--ignore`, `--warn`, and `--error`
|
|
#[test]
|
|
fn cli_rule_severity() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
import does_not_exit
|
|
|
|
y = 4 / 0
|
|
|
|
for a in range(0, int(y)):
|
|
x = a
|
|
|
|
print(x) # possibly-unresolved-reference
|
|
"#,
|
|
)?;
|
|
|
|
// Assert that there's a possibly unresolved reference diagnostic
|
|
// and that division-by-zero has a severity of error by default.
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/test.py:2:8
|
|
|
|
|
2 | import does_not_exit
|
|
| ^^^^^^^^^^^^^ Cannot resolve import `does_not_exit`
|
|
3 |
|
|
4 | y = 4 / 0
|
|
|
|
|
|
|
error: lint:division-by-zero
|
|
--> <temp_dir>/test.py:4:5
|
|
|
|
|
2 | import does_not_exit
|
|
3 |
|
|
4 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
5 |
|
|
6 | for a in range(0, int(y)):
|
|
|
|
|
|
|
warning: lint:possibly-unresolved-reference
|
|
--> <temp_dir>/test.py:9:7
|
|
|
|
|
7 | x = a
|
|
8 |
|
|
9 | print(x) # possibly-unresolved-reference
|
|
| ^ Name `x` used when possibly not defined
|
|
|
|
|
|
|
Found 3 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
assert_cmd_snapshot!(
|
|
case
|
|
.command()
|
|
.arg("--ignore")
|
|
.arg("possibly-unresolved-reference")
|
|
.arg("--warn")
|
|
.arg("division-by-zero")
|
|
.arg("--warn")
|
|
.arg("unresolved-import"),
|
|
@r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:unresolved-import
|
|
--> <temp_dir>/test.py:2:8
|
|
|
|
|
2 | import does_not_exit
|
|
| ^^^^^^^^^^^^^ Cannot resolve import `does_not_exit`
|
|
3 |
|
|
4 | y = 4 / 0
|
|
|
|
|
|
|
warning: lint:division-by-zero
|
|
--> <temp_dir>/test.py:4:5
|
|
|
|
|
2 | import does_not_exit
|
|
3 |
|
|
4 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
5 |
|
|
6 | for a in range(0, int(y)):
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The rule severity can be changed using `--ignore`, `--warn`, and `--error` and
|
|
/// values specified last override previous severities.
|
|
#[test]
|
|
fn cli_rule_severity_precedence() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
y = 4 / 0
|
|
|
|
for a in range(0, int(y)):
|
|
x = a
|
|
|
|
print(x) # possibly-unresolved-reference
|
|
"#,
|
|
)?;
|
|
|
|
// Assert that there's a possibly unresolved reference diagnostic
|
|
// and that division-by-zero has a severity of error by default.
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:division-by-zero
|
|
--> <temp_dir>/test.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
warning: lint:possibly-unresolved-reference
|
|
--> <temp_dir>/test.py:7:7
|
|
|
|
|
5 | x = a
|
|
6 |
|
|
7 | print(x) # possibly-unresolved-reference
|
|
| ^ Name `x` used when possibly not defined
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
assert_cmd_snapshot!(
|
|
case
|
|
.command()
|
|
.arg("--error")
|
|
.arg("possibly-unresolved-reference")
|
|
.arg("--warn")
|
|
.arg("division-by-zero")
|
|
// Override the error severity with warning
|
|
.arg("--ignore")
|
|
.arg("possibly-unresolved-reference"),
|
|
@r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:division-by-zero
|
|
--> <temp_dir>/test.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Red Knot warns about unknown rules specified in a configuration file
|
|
#[test]
|
|
fn configuration_unknown_rules() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"pyproject.toml",
|
|
r#"
|
|
[tool.knot.rules]
|
|
division-by-zer = "warn" # incorrect rule name
|
|
"#,
|
|
),
|
|
("test.py", "print(10)"),
|
|
])?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r#"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: unknown-rule
|
|
--> <temp_dir>/pyproject.toml:3:1
|
|
|
|
|
2 | [tool.knot.rules]
|
|
3 | division-by-zer = "warn" # incorrect rule name
|
|
| ^^^^^^^^^^^^^^^ Unknown lint rule `division-by-zer`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
"#);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Red Knot warns about unknown rules specified in a CLI argument
|
|
#[test]
|
|
fn cli_unknown_rules() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file("test.py", "print(10)")?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--ignore").arg("division-by-zer"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: unknown-rule: Unknown lint rule `division-by-zer`
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_only_warnings() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:1:7
|
|
|
|
|
1 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_only_info() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
from typing_extensions import reveal_type
|
|
reveal_type(1)
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
info: revealed-type: Revealed type
|
|
--> <temp_dir>/test.py:3:1
|
|
|
|
|
2 | from typing_extensions import reveal_type
|
|
3 | reveal_type(1)
|
|
| ^^^^^^^^^^^^^^ `Literal[1]`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_only_info_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
from typing_extensions import reveal_type
|
|
reveal_type(1)
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
info: revealed-type: Revealed type
|
|
--> <temp_dir>/test.py:3:1
|
|
|
|
|
2 | from typing_extensions import reveal_type
|
|
3 | reveal_type(1)
|
|
| ^^^^^^^^^^^^^^ `Literal[1]`
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:1:7
|
|
|
|
|
1 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_no_errors_but_error_on_warning_is_enabled_in_configuration() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
("test.py", r"print(x) # [unresolved-reference]"),
|
|
(
|
|
"knot.toml",
|
|
r#"
|
|
[terminal]
|
|
error-on-warning = true
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:1:7
|
|
|
|
|
1 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
|
|
|
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
print(x) # [unresolved-reference]
|
|
print(4[1]) # [non-subscriptable]
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command(), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:2:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
|
|
|
|
|
error: lint:non-subscriptable
|
|
--> <temp_dir>/test.py:3:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_both_warnings_and_errors_and_error_on_warning_is_true() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r###"
|
|
print(x) # [unresolved-reference]
|
|
print(4[1]) # [non-subscriptable]
|
|
"###,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--error-on-warning"), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:2:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
|
|
|
|
|
error: lint:non-subscriptable
|
|
--> <temp_dir>/test.py:3:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exit_code_exit_zero_is_true() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
print(x) # [unresolved-reference]
|
|
print(4[1]) # [non-subscriptable]
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--exit-zero"), @r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:unresolved-reference
|
|
--> <temp_dir>/test.py:2:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
| ^ Name `x` used when not defined
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
|
|
|
|
|
error: lint:non-subscriptable
|
|
--> <temp_dir>/test.py:3:7
|
|
|
|
|
2 | print(x) # [unresolved-reference]
|
|
3 | print(4[1]) # [non-subscriptable]
|
|
| ^ Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn user_configuration() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"project/knot.toml",
|
|
r#"
|
|
[rules]
|
|
division-by-zero = "warn"
|
|
"#,
|
|
),
|
|
(
|
|
"project/main.py",
|
|
r#"
|
|
y = 4 / 0
|
|
|
|
for a in range(0, int(y)):
|
|
x = a
|
|
|
|
print(x)
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
let config_directory = case.root().join("home/.config");
|
|
let config_env_var = if cfg!(windows) {
|
|
"APPDATA"
|
|
} else {
|
|
"XDG_CONFIG_HOME"
|
|
};
|
|
|
|
assert_cmd_snapshot!(
|
|
case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()),
|
|
@r"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
warning: lint:division-by-zero
|
|
--> <temp_dir>/project/main.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
warning: lint:possibly-unresolved-reference
|
|
--> <temp_dir>/project/main.py:7:7
|
|
|
|
|
5 | x = a
|
|
6 |
|
|
7 | print(x)
|
|
| ^ Name `x` used when possibly not defined
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
// The user-level configuration promotes `possibly-unresolved-reference` to an error.
|
|
// Changing the level for `division-by-zero` has no effect, because the project-level configuration
|
|
// has higher precedence.
|
|
case.write_file(
|
|
config_directory.join("knot/knot.toml"),
|
|
r#"
|
|
[rules]
|
|
division-by-zero = "error"
|
|
possibly-unresolved-reference = "error"
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(
|
|
case.command().current_dir(case.root().join("project")).env(config_env_var, config_directory.as_os_str()),
|
|
@r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning: lint:division-by-zero
|
|
--> <temp_dir>/project/main.py:2:5
|
|
|
|
|
2 | y = 4 / 0
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
3 |
|
|
4 | for a in range(0, int(y)):
|
|
|
|
|
|
|
error: lint:possibly-unresolved-reference
|
|
--> <temp_dir>/project/main.py:7:7
|
|
|
|
|
5 | x = a
|
|
6 |
|
|
7 | print(x)
|
|
| ^ Name `x` used when possibly not defined
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn check_specific_paths() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([
|
|
(
|
|
"project/main.py",
|
|
r#"
|
|
y = 4 / 0 # error: division-by-zero
|
|
"#,
|
|
),
|
|
(
|
|
"project/tests/test_main.py",
|
|
r#"
|
|
import does_not_exist # error: unresolved-import
|
|
"#,
|
|
),
|
|
(
|
|
"project/other.py",
|
|
r#"
|
|
from main2 import z # error: unresolved-import
|
|
|
|
print(z)
|
|
"#,
|
|
),
|
|
])?;
|
|
|
|
assert_cmd_snapshot!(
|
|
case.command(),
|
|
@r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/project/tests/test_main.py:2:8
|
|
|
|
|
2 | import does_not_exist # error: unresolved-import
|
|
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
|
|
|
|
|
|
error: lint:division-by-zero
|
|
--> <temp_dir>/project/main.py:2:5
|
|
|
|
|
2 | y = 4 / 0 # error: division-by-zero
|
|
| ^^^^^ Cannot divide object of type `Literal[4]` by zero
|
|
|
|
|
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/project/other.py:2:6
|
|
|
|
|
2 | from main2 import z # error: unresolved-import
|
|
| ^^^^^ Cannot resolve import `main2`
|
|
3 |
|
|
4 | print(z)
|
|
|
|
|
|
|
Found 3 diagnostics
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
// Now check only the `tests` and `other.py` files.
|
|
// We should no longer see any diagnostics related to `main.py`.
|
|
assert_cmd_snapshot!(
|
|
case.command().arg("project/tests").arg("project/other.py"),
|
|
@r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/project/tests/test_main.py:2:8
|
|
|
|
|
2 | import does_not_exist # error: unresolved-import
|
|
| ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist`
|
|
|
|
|
|
|
error: lint:unresolved-import
|
|
--> <temp_dir>/project/other.py:2:6
|
|
|
|
|
2 | from main2 import z # error: unresolved-import
|
|
| ^^^^^ Cannot resolve import `main2`
|
|
3 |
|
|
4 | print(z)
|
|
|
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn check_non_existing_path() -> anyhow::Result<()> {
|
|
let case = TestCase::with_files([])?;
|
|
|
|
let mut settings = insta::Settings::clone_current();
|
|
settings.add_filter(
|
|
®ex::escape("The system cannot find the path specified. (os error 3)"),
|
|
"No such file or directory (os error 2)",
|
|
);
|
|
let _s = settings.bind_to_scope();
|
|
|
|
assert_cmd_snapshot!(
|
|
case.command().arg("project/main.py").arg("project/tests"),
|
|
@r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
error: io: `<temp_dir>/project/main.py`: No such file or directory (os error 2)
|
|
|
|
error: io: `<temp_dir>/project/tests`: No such file or directory (os error 2)
|
|
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
WARN No python files found under the given path(s)
|
|
"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn concise_diagnostics() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
print(x) # [unresolved-reference]
|
|
print(4[1]) # [non-subscriptable]
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r"
|
|
success: false
|
|
exit_code: 1
|
|
----- stdout -----
|
|
warning[lint:unresolved-reference] <temp_dir>/test.py:2:7: Name `x` used when not defined
|
|
error[lint:non-subscriptable] <temp_dir>/test.py:3:7: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
|
|
Found 2 diagnostics
|
|
|
|
----- stderr -----
|
|
");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// This tests the diagnostic format for revealed type.
|
|
///
|
|
/// This test was introduced because changes were made to
|
|
/// how the revealed type diagnostic was constructed and
|
|
/// formatted in "verbose" mode. But it required extra
|
|
/// logic to ensure the concise version didn't regress on
|
|
/// information content. So this test was introduced to
|
|
/// capture that.
|
|
#[test]
|
|
fn concise_revealed_type() -> anyhow::Result<()> {
|
|
let case = TestCase::with_file(
|
|
"test.py",
|
|
r#"
|
|
from typing_extensions import reveal_type
|
|
|
|
x = "hello"
|
|
reveal_type(x)
|
|
"#,
|
|
)?;
|
|
|
|
assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r#"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
info[revealed-type] <temp_dir>/test.py:5:1: Revealed type: `Literal["hello"]`
|
|
Found 1 diagnostic
|
|
|
|
----- stderr -----
|
|
"#);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct TestCase {
|
|
_temp_dir: TempDir,
|
|
_settings_scope: SettingsBindDropGuard,
|
|
project_dir: PathBuf,
|
|
}
|
|
|
|
impl TestCase {
|
|
fn new() -> anyhow::Result<Self> {
|
|
let temp_dir = TempDir::new()?;
|
|
|
|
// Canonicalize the tempdir path because macos uses symlinks for tempdirs
|
|
// and that doesn't play well with our snapshot filtering.
|
|
let project_dir = temp_dir
|
|
.path()
|
|
.canonicalize()
|
|
.context("Failed to canonicalize project path")?;
|
|
|
|
let mut settings = insta::Settings::clone_current();
|
|
settings.add_filter(&tempdir_filter(&project_dir), "<temp_dir>/");
|
|
settings.add_filter(r#"\\(\w\w|\s|\.|")"#, "/$1");
|
|
|
|
let settings_scope = settings.bind_to_scope();
|
|
|
|
Ok(Self {
|
|
project_dir,
|
|
_temp_dir: temp_dir,
|
|
_settings_scope: settings_scope,
|
|
})
|
|
}
|
|
|
|
fn with_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> anyhow::Result<Self> {
|
|
let case = Self::new()?;
|
|
case.write_files(files)?;
|
|
Ok(case)
|
|
}
|
|
|
|
fn with_file(path: impl AsRef<Path>, content: &str) -> anyhow::Result<Self> {
|
|
let case = Self::new()?;
|
|
case.write_file(path, content)?;
|
|
Ok(case)
|
|
}
|
|
|
|
fn write_files<'a>(
|
|
&self,
|
|
files: impl IntoIterator<Item = (&'a str, &'a str)>,
|
|
) -> anyhow::Result<()> {
|
|
for (path, content) in files {
|
|
self.write_file(path, content)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn write_file(&self, path: impl AsRef<Path>, content: &str) -> anyhow::Result<()> {
|
|
let path = path.as_ref();
|
|
let path = self.project_dir.join(path);
|
|
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create directory `{}`", parent.display()))?;
|
|
}
|
|
std::fs::write(&path, &*ruff_python_trivia::textwrap::dedent(content))
|
|
.with_context(|| format!("Failed to write file `{path}`", path = path.display()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn root(&self) -> &Path {
|
|
&self.project_dir
|
|
}
|
|
|
|
fn command(&self) -> Command {
|
|
let mut command = Command::new(get_cargo_bin("red_knot"));
|
|
command.current_dir(&self.project_dir).arg("check");
|
|
command
|
|
}
|
|
}
|
|
|
|
fn tempdir_filter(path: &Path) -> String {
|
|
format!(r"{}\\?/?", regex::escape(path.to_str().unwrap()))
|
|
}
|