Skip to content
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

[ruff] Ambiguous pattern passed to pytest.raises() (RUF043) #14966

Merged
merged 10 commits into from
Dec 18, 2024
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import re
import pytest

def test_foo():
### Errors

with pytest.raises(FooAtTheEnd, match="foo."): ...

with pytest.raises(PackageExtraSpecifier, match="Install `foo[bar]` instead"): ...

with pytest.raises(InnocentQuestion, match="This is a simple sentence (or is it?)"): ...


### No errors

with pytest.raises(NoMatch): ...

with pytest.raises(NonLiteral, match=pattern): ...

with pytest.raises(FunctionCall, match=frobnicate("qux")): ...

with pytest.raises(ReEscaped, match=re.escape("foobar")): ...

with pytest.raises(RawString, match=r"fo()bar"): ...

with pytest.raises(RawStringPart, match=r"foo" '\bar'): ...

with pytest.raises(NoMetacharacters, match="foobar"): ...
1 change: 1 addition & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.any_enabled(&[
Rule::PytestRaisesWithoutException,
Rule::PytestRaisesTooBroad,
Rule::PytestRaisesAmbiguousPattern,
]) {
flake8_pytest_style::rules::raises_call(checker, call);
}
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8PytestStyle, "025") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestErroneousUseFixturesOnFixture),
(Flake8PytestStyle, "026") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestUseFixturesWithoutParameters),
(Flake8PytestStyle, "027") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestUnittestRaisesAssertion),
(Flake8PytestStyle, "201") => (RuleGroup::Preview, rules::flake8_pytest_style::rules::PytestRaisesAmbiguousPattern),

// flake8-pie
(Flake8Pie, "790") => (RuleGroup::Stable, rules::flake8_pie::rules::UnnecessaryPlaceholder),
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ mod tests {
}

#[test_case(Rule::PytestParametrizeNamesWrongType, Path::new("PT006.py"))]
#[test_case(Rule::PytestRaisesAmbiguousPattern, Path::new("PT201.py"))]
fn test_pytest_style_preview(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"preview__{}_{}",
Expand Down
68 changes: 65 additions & 3 deletions crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::helpers::is_compound_statement;
use ruff_python_ast::{self as ast, Expr, Stmt, WithItem};
use ruff_python_ast::{self as ast, Expr, Keyword, Stmt, WithItem};
use ruff_python_semantic::SemanticModel;
use ruff_text_size::Ranged;

use super::helpers::is_empty_or_null_string;
use crate::checkers::ast::Checker;
use crate::registry::Rule;

use super::helpers::is_empty_or_null_string;
use crate::rules::ruff::rules::string_has_metacharacters;

/// ## What it does
/// Checks for `pytest.raises` context managers with multiple statements.
Expand Down Expand Up @@ -151,6 +151,53 @@ impl Violation for PytestRaisesWithoutException {
}
}

/// ## What it does
/// Checks for non-raw literal string arguments passed to the `match` parameter
/// of `pytest.raises()` that contains at least one regex metacharacters.
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
///
/// ## Why is this bad?
/// The `match` argument is implicitly converted to a regex under the hood.
/// It should be made explicit whether the string is meant to be a regex or a "plain" pattern
/// by either prefixing the string with the `r` suffix or wrapping it in `re.escape()`.
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
///
/// ## Example
///
/// ```python
/// with pytest.raises(Exception, match="foo."):
/// do_thing_that_raises()
/// ```
///
/// Use instead:
///
/// ```python
/// with pytest.raises(Exception, match=r"foo."):
/// do_thing_that_raises()
/// ```
///
/// Alternatively:
///
/// ```python
/// import re
///
///
/// with pytest.raises(Exception, match=re.escape("foo.")):
/// do_thing_that_raises()
/// ```
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
#[derive(ViolationMetadata)]
pub(crate) struct PytestRaisesAmbiguousPattern;

impl Violation for PytestRaisesAmbiguousPattern {
#[derive_message_formats]
fn message(&self) -> String {
"Pattern passed to `match=` contains metacharacters but is neither escaped nor raw"
.to_string()
}

fn fix_title(&self) -> Option<String> {
Some("Use a raw string or `re.escape()` to make the intention explicit".to_string())
}
}

fn is_pytest_raises(func: &Expr, semantic: &SemanticModel) -> bool {
semantic
.resolve_qualified_name(func)
Expand All @@ -176,6 +223,21 @@ pub(crate) fn raises_call(checker: &mut Checker, call: &ast::ExprCall) {
}
}

if checker.enabled(Rule::PytestRaisesAmbiguousPattern) {
if let Some(Keyword { value, .. }) = call.arguments.find_keyword("match") {
if let Some(string) = value.as_string_literal_expr() {
let any_part_is_raw =
string.value.iter().any(|part| part.flags.prefix().is_raw());

if !any_part_is_raw && string_has_metacharacters(&string.value) {
let diagnostic =
Diagnostic::new(PytestRaisesAmbiguousPattern, string.range);
checker.diagnostics.push(diagnostic);
}
}
}
}

if checker.enabled(Rule::PytestRaisesTooBroad) {
if let Some(exception) = call.arguments.find_argument("expected_exception", 0) {
if call
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs
snapshot_kind: text
---
PT201.py:7:43: PT201 Pattern passed to `match=` contains metacharacters but is neither escaped nor raw
|
5 | ### Errors
6 |
7 | with pytest.raises(FooAtTheEnd, match="foo."): ...
| ^^^^^^ PT201
8 |
9 | with pytest.raises(PackageExtraSpecifier, match="Install `foo[bar]` instead"): ...
|
= help: Use a raw string or `re.escape()` to make the intention explicit

PT201.py:9:53: PT201 Pattern passed to `match=` contains metacharacters but is neither escaped nor raw
|
7 | with pytest.raises(FooAtTheEnd, match="foo."): ...
8 |
9 | with pytest.raises(PackageExtraSpecifier, match="Install `foo[bar]` instead"): ...
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PT201
10 |
11 | with pytest.raises(InnocentQuestion, match="This is a simple sentence (or is it?)"): ...
|
= help: Use a raw string or `re.escape()` to make the intention explicit

PT201.py:11:48: PT201 Pattern passed to `match=` contains metacharacters but is neither escaped nor raw
|
9 | with pytest.raises(PackageExtraSpecifier, match="Install `foo[bar]` instead"): ...
10 |
11 | with pytest.raises(InnocentQuestion, match="This is a simple sentence (or is it?)"): ...
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PT201
|
= help: Use a raw string or `re.escape()` to make the intention explicit
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Vi
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{
Arguments, CmpOp, Expr, ExprAttribute, ExprCall, ExprCompare, ExprContext, ExprStringLiteral,
Identifier,
Identifier, StringLiteralValue,
};
use ruff_python_semantic::analyze::typing::find_binding_value;
use ruff_python_semantic::{Modules, SemanticModel};
Expand Down Expand Up @@ -100,12 +100,8 @@ pub(crate) fn unnecessary_regular_expression(checker: &mut Checker, call: &ExprC
return;
};

// For now, reject any regex metacharacters. Compare to the complete list
// from https://docs.python.org/3/howto/regex.html#matching-characters
let has_metacharacters = string_lit
.value
.to_str()
.contains(['.', '^', '$', '*', '+', '?', '{', '[', '\\', '|', '(']);
// For now, reject any regex metacharacters.
let has_metacharacters = string_has_metacharacters(&string_lit.value);

if has_metacharacters {
return;
Expand Down Expand Up @@ -140,6 +136,14 @@ pub(crate) fn unnecessary_regular_expression(checker: &mut Checker, call: &ExprC
checker.diagnostics.push(diagnostic);
}

pub(crate) fn string_has_metacharacters(value: &StringLiteralValue) -> bool {
// Compare to the complete list from
// https://docs.python.org/3/howto/regex.html#matching-characters
value
.to_str()
.contains(['.', '^', '$', '*', '+', '?', '{', '[', '\\', '|', '('])
}
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved

/// The `re` functions supported by this rule.
#[derive(Debug)]
enum ReFuncKind<'a> {
Expand Down
3 changes: 3 additions & 0 deletions ruff.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading