[`flake8-bugbear`] Add `B912`: `map()` without an explicit `strict=` parameter (#20429)

## Summary

Implements new rule `B912` that requires the `strict=` argument for
`map(...)` calls with two or more iterables on Python 3.14+, following
the same pattern as `B905` for `zip()`.

Closes #20057

---------

Co-authored-by: dylwil3 <dylwil3@gmail.com>
This commit is contained in:
Dan Parizher 2025-09-19 13:54:44 -04:00 committed by GitHub
parent bae8ddfb8a
commit c94ddb590f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 357 additions and 52 deletions

View File

@ -0,0 +1,33 @@
from itertools import count, cycle, repeat
# Errors
map(lambda x: x, [1, 2, 3])
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
# Errors (limited iterators).
map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4))
import builtins
# Still an error even though it uses the qualified name
builtins.map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
# OK
map(lambda x: x, [1, 2, 3], strict=True)
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], strict=True)
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], strict=False)
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=True)
map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True), strict=True)
# OK (single iterable - no strict required)
map(lambda x: x, [1, 2, 3])
# OK (infinite iterators)
map(lambda x, y: x + y, [1, 2, 3], cycle([1, 2, 3]))
map(lambda x, y: x + y, [1, 2, 3], repeat(1))
map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=None))
map(lambda x, y: x + y, [1, 2, 3], count())

View File

@ -741,6 +741,11 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
flake8_bugbear::rules::zip_without_explicit_strict(checker, call);
}
}
if checker.is_rule_enabled(Rule::MapWithoutExplicitStrict) {
if checker.target_version() >= PythonVersion::PY314 {
flake8_bugbear::rules::map_without_explicit_strict(checker, call);
}
}
if checker.is_rule_enabled(Rule::NoExplicitStacklevel) {
flake8_bugbear::rules::no_explicit_stacklevel(checker, call);
}

View File

@ -394,6 +394,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict),
(Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation),
(Flake8Bugbear, "911") => (RuleGroup::Stable, rules::flake8_bugbear::rules::BatchedWithoutExplicitStrict),
(Flake8Bugbear, "912") => (RuleGroup::Preview, rules::flake8_bugbear::rules::MapWithoutExplicitStrict),
// flake8-blind-except
(Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept),

View File

@ -4,6 +4,7 @@ use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextRange};
use crate::Locator;
use ruff_python_ast::{self as ast, Arguments, Expr};
/// Return `true` if the statement containing the current expression is the last
/// top-level expression in the cell. This assumes that the source is a Jupyter
@ -27,3 +28,54 @@ pub(super) fn at_last_top_level_expression_in_cell(
.all(|token| token.kind() == SimpleTokenKind::Semi || token.kind().is_trivia())
})
}
/// Return `true` if the [`Expr`] appears to be an infinite iterator (e.g., a call to
/// `itertools.cycle` or similar).
pub(crate) fn is_infinite_iterable(arg: &Expr, semantic: &SemanticModel) -> bool {
let Expr::Call(ast::ExprCall {
func,
arguments: Arguments { args, keywords, .. },
..
}) = &arg
else {
return false;
};
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| match qualified_name.segments() {
["itertools", "cycle" | "count"] => true,
["itertools", "repeat"] => {
// Ex) `itertools.repeat(1)`
if keywords.is_empty() && args.len() == 1 {
return true;
}
// Ex) `itertools.repeat(1, None)`
if args.len() == 2 && args[1].is_none_literal_expr() {
return true;
}
// Ex) `itertools.repeat(1, times=None)`
for keyword in keywords {
if keyword.arg.as_ref().is_some_and(|name| name == "times")
&& keyword.value.is_none_literal_expr()
{
return true;
}
}
false
}
_ => false,
})
}
/// Return `true` if any expression in the iterator appears to be an infinite iterator.
pub(crate) fn any_infinite_iterables<'a>(
iter: impl IntoIterator<Item = &'a Expr>,
semantic: &SemanticModel,
) -> bool {
iter.into_iter()
.any(|arg| is_infinite_iterable(arg, semantic))
}

View File

@ -16,6 +16,7 @@ mod tests {
use crate::settings::LinterSettings;
use crate::test::test_path;
use crate::settings::types::PreviewMode;
use ruff_python_ast::PythonVersion;
#[test_case(Rule::AbstractBaseClassWithoutAbstractMethod, Path::new("B024.py"))]
@ -81,11 +82,35 @@ mod tests {
Ok(())
}
#[test_case(Rule::MapWithoutExplicitStrict, Path::new("B912.py"))]
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"preview__{}_{}",
rule_code.noqa_code(),
path.to_string_lossy()
);
let diagnostics = test_path(
Path::new("flake8_bugbear").join(path).as_path(),
&LinterSettings {
preview: PreviewMode::Enabled,
unresolved_target_version: PythonVersion::PY314.into(),
..LinterSettings::for_rule(rule_code)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test_case(
Rule::ClassAsDataStructure,
Path::new("class_as_data_structure.py"),
PythonVersion::PY39
)]
#[test_case(
Rule::MapWithoutExplicitStrict,
Path::new("B912.py"),
PythonVersion::PY313
)]
fn rules_with_target_version(
rule_code: Rule,
path: &Path,

View File

@ -3,7 +3,7 @@ use ruff_python_ast::ExprCall;
use ruff_python_ast::PythonVersion;
use crate::checkers::ast::Checker;
use crate::rules::flake8_bugbear::rules::is_infinite_iterable;
use crate::rules::flake8_bugbear::helpers::is_infinite_iterable;
use crate::{FixAvailability, Violation};
/// ## What it does

View File

@ -0,0 +1,89 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::fix::edits::add_argument;
use crate::rules::flake8_bugbear::helpers::any_infinite_iterables;
use crate::{AlwaysFixableViolation, Applicability, Fix};
/// ## What it does
/// Checks for `map` calls without an explicit `strict` parameter when called with two or more iterables.
///
/// This rule applies to Python 3.14 and later, where `map` accepts a `strict` keyword
/// argument. For details, see: [Whats New in Python 3.14](https://docs.python.org/dev/whatsnew/3.14.html).
///
/// ## Why is this bad?
/// By default, if the iterables passed to `map` are of different lengths, the
/// resulting iterator will be silently truncated to the length of the shortest
/// iterable. This can lead to subtle bugs.
///
/// Pass `strict=True` to raise a `ValueError` if the iterables are of
/// non-uniform length. Alternatively, if the iterables are deliberately of
/// different lengths, pass `strict=False` to make the intention explicit.
///
/// ## Example
/// ```python
/// map(f, a, b)
/// ```
///
/// Use instead:
/// ```python
/// map(f, a, b, strict=True)
/// ```
///
/// ## Fix safety
/// This rule's fix is marked as unsafe for `map` calls that contain
/// `**kwargs`, as adding a `strict` keyword argument to such a call may lead
/// to a duplicate keyword argument error.
///
/// ## References
/// - [Python documentation: `map`](https://docs.python.org/3/library/functions.html#map)
/// - [Whats New in Python 3.14](https://docs.python.org/dev/whatsnew/3.14.html)
#[derive(ViolationMetadata)]
pub(crate) struct MapWithoutExplicitStrict;
impl AlwaysFixableViolation for MapWithoutExplicitStrict {
#[derive_message_formats]
fn message(&self) -> String {
"`map()` without an explicit `strict=` parameter".to_string()
}
fn fix_title(&self) -> String {
"Add explicit value for parameter `strict=`".to_string()
}
}
/// B912
pub(crate) fn map_without_explicit_strict(checker: &Checker, call: &ast::ExprCall) {
let semantic = checker.semantic();
if semantic.match_builtin_expr(&call.func, "map")
&& call.arguments.find_keyword("strict").is_none()
&& call.arguments.args.len() >= 3 // function + at least 2 iterables
&& !any_infinite_iterables(call.arguments.args.iter().skip(1), semantic)
{
checker
.report_diagnostic(MapWithoutExplicitStrict, call.range())
.set_fix(Fix::applicable_edit(
add_argument(
"strict=False",
&call.arguments,
checker.comment_ranges(),
checker.locator().contents(),
),
// If the function call contains `**kwargs`, mark the fix as unsafe.
if call
.arguments
.keywords
.iter()
.any(|keyword| keyword.arg.is_none())
{
Applicability::Unsafe
} else {
Applicability::Safe
},
));
}
}

View File

@ -16,6 +16,7 @@ pub(crate) use getattr_with_constant::*;
pub(crate) use jump_statement_in_finally::*;
pub(crate) use loop_iterator_mutation::*;
pub(crate) use loop_variable_overrides_iterator::*;
pub(crate) use map_without_explicit_strict::*;
pub(crate) use mutable_argument_default::*;
pub(crate) use mutable_contextvar_default::*;
pub(crate) use no_explicit_stacklevel::*;
@ -56,6 +57,7 @@ mod getattr_with_constant;
mod jump_statement_in_finally;
mod loop_iterator_mutation;
mod loop_variable_overrides_iterator;
mod map_without_explicit_strict;
mod mutable_argument_default;
mod mutable_contextvar_default;
mod no_explicit_stacklevel;

View File

@ -1,11 +1,11 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Arguments, Expr};
use ruff_python_semantic::SemanticModel;
use ruff_python_ast::{self as ast};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::fix::edits::add_argument;
use crate::rules::flake8_bugbear::helpers::any_infinite_iterables;
use crate::{AlwaysFixableViolation, Applicability, Fix};
/// ## What it does
@ -57,11 +57,7 @@ pub(crate) fn zip_without_explicit_strict(checker: &Checker, call: &ast::ExprCal
if semantic.match_builtin_expr(&call.func, "zip")
&& call.arguments.find_keyword("strict").is_none()
&& !call
.arguments
.args
.iter()
.any(|arg| is_infinite_iterable(arg, semantic))
&& !any_infinite_iterables(call.arguments.args.iter(), semantic)
{
checker
.report_diagnostic(ZipWithoutExplicitStrict, call.range())
@ -86,47 +82,3 @@ pub(crate) fn zip_without_explicit_strict(checker: &Checker, call: &ast::ExprCal
));
}
}
/// Return `true` if the [`Expr`] appears to be an infinite iterator (e.g., a call to
/// `itertools.cycle` or similar).
pub(crate) fn is_infinite_iterable(arg: &Expr, semantic: &SemanticModel) -> bool {
let Expr::Call(ast::ExprCall {
func,
arguments: Arguments { args, keywords, .. },
..
}) = &arg
else {
return false;
};
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| {
match qualified_name.segments() {
["itertools", "cycle" | "count"] => true,
["itertools", "repeat"] => {
// Ex) `itertools.repeat(1)`
if keywords.is_empty() && args.len() == 1 {
return true;
}
// Ex) `itertools.repeat(1, None)`
if args.len() == 2 && args[1].is_none_literal_expr() {
return true;
}
// Ex) `iterools.repeat(1, times=None)`
for keyword in keywords {
if keyword.arg.as_ref().is_some_and(|name| name == "times") {
if keyword.value.is_none_literal_expr() {
return true;
}
}
}
false
}
_ => false,
}
})
}

View File

@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
---

View File

@ -0,0 +1,141 @@
---
source: crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
---
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:5:1
|
3 | # Errors
4 | map(lambda x: x, [1, 2, 3])
5 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
|
help: Add explicit value for parameter `strict=`
2 |
3 | # Errors
4 | map(lambda x: x, [1, 2, 3])
- map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
5 + map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], strict=False)
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:6:1
|
4 | map(lambda x: x, [1, 2, 3])
5 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
|
help: Add explicit value for parameter `strict=`
3 | # Errors
4 | map(lambda x: x, [1, 2, 3])
5 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
- map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
6 + map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9], strict=False)
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
9 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:7:1
|
5 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
9 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
|
help: Add explicit value for parameter `strict=`
4 | map(lambda x: x, [1, 2, 3])
5 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
- map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
8 + map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
9 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
10 |
11 | # Errors (limited iterators).
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:9:1
|
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
9 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 |
11 | # Errors (limited iterators).
|
help: Add explicit value for parameter `strict=`
6 | map(lambda x, y, z: x + y + z, [1, 2, 3], [4, 5, 6], [7, 8, 9])
7 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]))
8 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9]), strict=False)
- map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
9 + map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True), strict=False)
10 |
11 | # Errors (limited iterators).
12 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:12:1
|
11 | # Errors (limited iterators).
12 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4))
|
help: Add explicit value for parameter `strict=`
9 | map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], *map(lambda x: x, [7, 8, 9], strict=True))
10 |
11 | # Errors (limited iterators).
- map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
12 + map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1), strict=False)
13 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4))
14 |
15 | import builtins
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:13:1
|
11 | # Errors (limited iterators).
12 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
13 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |
15 | import builtins
|
help: Add explicit value for parameter `strict=`
10 |
11 | # Errors (limited iterators).
12 | map(lambda x, y: x + y, [1, 2, 3], repeat(1, 1))
- map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4))
13 + map(lambda x, y: x + y, [1, 2, 3], repeat(1, times=4), strict=False)
14 |
15 | import builtins
16 | # Still an error even though it uses the qualified name
B912 [*] `map()` without an explicit `strict=` parameter
--> B912.py:17:1
|
15 | import builtins
16 | # Still an error even though it uses the qualified name
17 | builtins.map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
18 |
19 | # OK
|
help: Add explicit value for parameter `strict=`
14 |
15 | import builtins
16 | # Still an error even though it uses the qualified name
- builtins.map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6])
17 + builtins.map(lambda x, y: x + y, [1, 2, 3], [4, 5, 6], strict=False)
18 |
19 | # OK
20 | map(lambda x: x, [1, 2, 3], strict=True)

1
ruff.schema.json generated
View File

@ -3073,6 +3073,7 @@
"B909",
"B91",
"B911",
"B912",
"BLE",
"BLE0",
"BLE00",