Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README-pypi.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@

<p align="center">
<a href="https://www.basilisk-python.dev/docs/conformance/"><strong><!--g:score-->100.0%<!--/g:score--> PEP conformance</strong></a> &mdash; <!--g:pass-->141<!--/g:pass--> of <!--g:total-->141<!--/g:total--> tests in the official
<a href="https://github.com/python/typing/tree/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/conformance"><code>python/typing</code></a>
conformance suite (commit <code><!--g:short-->6ef9f77<!--/g:short--></code>), scored on the wheel-installed CLI in its default config by the real upstream harness.
<a href="https://github.com/python/typing/tree/39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8/conformance"><code>python/typing</code></a>
conformance suite (commit <code><!--g:short-->39164cd<!--/g:short--></code>), scored on the wheel-installed CLI in its default config by the real upstream harness.
We target <code>python/typing@main</code> and ratchet the score up only.
</p>

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@

<p align="center">
<a href="https://www.basilisk-python.dev/docs/conformance/"><strong><!--g:score-->100.0%<!--/g:score--> PEP conformance</strong></a> &mdash; <!--g:pass-->141<!--/g:pass--> of <!--g:total-->141<!--/g:total--> tests in the official
<a href="https://github.com/python/typing/tree/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/conformance"><code>python/typing</code></a>
conformance suite (commit <code><!--g:short-->6ef9f77<!--/g:short--></code>), scored on the wheel-installed CLI in its default config by the real upstream harness.
<a href="https://github.com/python/typing/tree/39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8/conformance"><code>python/typing</code></a>
conformance suite (commit <code><!--g:short-->39164cd<!--/g:short--></code>), scored on the wheel-installed CLI in its default config by the real upstream harness.
We target <code>python/typing@main</code> and ratchet the score up only.
</p>

Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@

<p align="center">
<a href="https://www.basilisk-python.dev/zh/docs/conformance/"><strong>PEP 一致性 <!--g:score-->100.0%<!--/g:score--></strong></a> &mdash; 官方
<a href="https://github.com/python/typing/tree/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/conformance"><code>python/typing</code></a>
一致性套件(提交 <code><!--g:short-->6ef9f77<!--/g:short--></code>)<!--g:total-->141<!--/g:total--> 项测试中通过 <!--g:pass-->141<!--/g:pass--> 项,
<a href="https://github.com/python/typing/tree/39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8/conformance"><code>python/typing</code></a>
一致性套件(提交 <code><!--g:short-->39164cd<!--/g:short--></code>)<!--g:total-->141<!--/g:total--> 项测试中通过 <!--g:pass-->141<!--/g:pass--> 项,
由真实的上游评分器在默认配置下对 wheel 安装的 CLI 评出。
我们以 <code>python/typing@main</code> 为目标,且分数只升不降。
</p>
Expand Down
42 changes: 42 additions & 0 deletions crates/basilisk-checker/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,52 @@ pub fn literal_collection_assignable_to(rhs: &RhsKind, declared: &InferredType)
})),
_ => None,
},
InferredType::Tuple(declared_elems) => match rhs {
RhsKind::Tuple(elements) => tuple_literal_assignable_to(elements, declared_elems),
_ => None,
},
_ => None,
}
}

/// Contextual typing for a tuple display against a declared tuple type: each
/// position carries the declared element type inward, so an empty `[]`/`{}`
/// nested in the tuple constructs the declared container instead of a
/// `list[Never]` / `dict[Never, Never]` that then fails invariance (#337).
///
/// Implements [TYPEINF-SPECIAL-LITERAL-CONTEXT] for the tuple shapes of
/// [TYPEINF-COLLECTIONS-TUPLES]. Returns `None` for shapes this cannot judge —
/// a PEP 646 unpacked segment or an arity mismatch — leaving those to the
/// invariant fallback unchanged.
fn tuple_literal_assignable_to(
elements: &[RhsKind],
declared_elems: &[InferredType],
) -> Option<bool> {
if declared_elems
.iter()
.any(crate::types_star_tuples::is_unpacked_tuple_elem)
{
return None;
}
// `tuple[X, ...]` (PEP 484 homogeneous): every position is typed against `X`.
if let Some(elem) = crate::types_star_tuples::homogeneous_tuple_elem(declared_elems) {
return Some(
elements
.iter()
.all(|element| literal_element_assignable_to(element, elem)),
);
}
if elements.len() != declared_elems.len() {
return None;
}
Some(
elements
.iter()
.zip(declared_elems)
.all(|(element, declared)| literal_element_assignable_to(element, declared)),
)
}

/// Assignability of a single literal element: a nested collection literal stays
/// covariant/contextual; anything else uses its ordinary inferred type.
fn literal_element_assignable_to(rhs: &RhsKind, declared: &InferredType) -> bool {
Expand Down
31 changes: 31 additions & 0 deletions crates/basilisk-checker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,37 @@ pub fn pep_disable_violations(config: &basilisk_config::BasiliskConfig) -> Vec<&
.collect()
}

/// Analyze-scope rules this configuration selects — the rules `check` will
/// never evaluate, however strictly they are graded.
///
/// Implements [CHKARCH-CLI-SCOPE-NOTICE] (GitHub #334). The counterpart to
/// [`pep_disable_violations`]: a rule is analyze-scope iff it is not
/// `pep`-tagged ([CHKARCH-COMMANDS]), and configuration selects it by
/// resolving it to a non-disabled severity ([CHKARCH-CONFIG-MODEL]). Callers
/// report the count so a clean `check` on such a project is never mistaken for
/// a clean project.
#[must_use]
pub fn analyze_selected_rules(config: &basilisk_config::BasiliskConfig) -> Vec<&'static str> {
// Bare-tree fast path, as in [`EffectiveRuleConfig::severity`]: with no
// table anywhere nothing is selected, so the conformance and benchmark
// hot paths never walk the catalog.
if !config.has_config_table() {
return Vec::new();
}
rule_catalog()
.into_iter()
.map(|descriptor| descriptor.code)
.filter(|code| !is_pep_rule(code))
.filter(|code| {
let tags = rule_tags::tags_for_code(code);
matches!(
config.resolve_severity(code, &tags),
Some(severity) if severity != basilisk_config::RuleSeverity::Disabled
)
})
.collect()
}

/// Per-file rule selection and grading over the configuration model.
///
/// Implements [CHKARCH-CONFIG-MODEL]: the nearest deciding table wins, a rule
Expand Down
79 changes: 79 additions & 0 deletions crates/basilisk-checker/tests/checker/names_undefined_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,82 @@ fn function_local_aliased_import_attribute_in_return_no_false_positive(
);
Ok(())
}

// ---------------------------------------------------------------------------
// Issue #339 — the walrus operator (`:=`, PEP 572) binds its target
// ---------------------------------------------------------------------------

#[test]
fn walrus_in_if_test_binds_the_name_for_a_return_inside_the_branch(
) -> Result<(), Box<dyn std::error::Error>> {
// Issue #339 (headline repro): `if item := mapping.get(key):` binds `item`
// for the branch body, so `return item` references a defined name. PEP 572
// assignment expressions bind in the *enclosing* scope exactly like `=`.
let source = "\
def get_price(prices: dict[str, float], asset: str) -> float | None:
if item := prices.get(asset):
return item
return None
";
let diags = run(source)?;
let fired: Vec<&str> = codes(&diags)
.into_iter()
.filter(|c| *c == "names_undefined" || *c == "names_unbound")
.collect();
assert!(
fired.is_empty(),
"a walrus target used in a return inside the guarded branch must fire neither \
E0018 nor E0019, got: {fired:?}"
);
Ok(())
}

#[test]
fn walrus_in_while_test_binds_the_name_for_a_return_inside_the_loop(
) -> Result<(), Box<dyn std::error::Error>> {
// Issue #339 (loop shape): the other canonical walrus site is a `while`
// test, where the binding is equally visible to the loop body.
let source = "\
def first_line(lines: list[str]) -> str | None:
while line := lines.pop():
return line
return None
";
let diags = run(source)?;
let fired: Vec<&str> = codes(&diags)
.into_iter()
.filter(|c| *c == "names_undefined" || *c == "names_unbound")
.collect();
assert!(
fired.is_empty(),
"a walrus target bound by a `while` test must fire neither E0018 nor E0019, \
got: {fired:?}"
);
Ok(())
}

#[test]
fn walrus_in_if_test_binds_the_name_for_a_later_top_level_return(
) -> Result<(), Box<dyn std::error::Error>> {
// Issue #339 (post-branch shape): an `if` test always evaluates, so a walrus
// inside it binds unconditionally — the name is live after the statement,
// whichever way the branch went. Recognising the binding must therefore not
// merely trade E0018 ("not defined") for E0019 ("may be unbound").
let source = "\
def lookup(items: dict[str, int], key: str) -> int | None:
if hit := items.get(key):
print(hit)
return hit
";
let diags = run(source)?;
let fired: Vec<&str> = codes(&diags)
.into_iter()
.filter(|c| *c == "names_undefined" || *c == "names_unbound")
.collect();
assert!(
fired.is_empty(),
"an `if`-test walrus binds unconditionally, so a later top-level return of the \
name must fire neither E0018 nor E0019, got: {fired:?}"
);
Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,92 @@ fn empty_dict_return_is_checked_in_declared_context() -> Result<(), Box<dyn std:
);
Ok(())
}

#[test]
fn empty_list_inside_returned_tuple_uses_declared_element_context(
) -> Result<(), Box<dyn std::error::Error>> {
// Refs #337. [TYPEINF-SPECIAL-LITERAL-CONTEXT] must reach the tuple's element
// positions: `return [], "lit"` against `-> tuple[list[int], str]` constructs
// a `list[int]` instead of a `list[Never]` that then fails invariance. Locks
// the Tuple arm of `literal_collection_assignable_to` (inference.rs).
let source = "def f() -> tuple[list[int], str]:\n return [], \"lit\"\n";
let diags = run(source)?;
assert!(
!codes(&diags).contains(&"returns_compatibility"),
"empty list inside a returned tuple must be typed against tuple[list[int], str], got: {:?}",
codes(&diags)
);
Ok(())
}

#[test]
fn empty_dict_inside_returned_tuple_uses_declared_element_context(
) -> Result<(), Box<dyn std::error::Error>> {
// Refs #337. Same propagation for the `{}` display: `dict[Never, Never]`
// must never be synthesised when a declared element type is available.
let source = "def f() -> tuple[dict[str, int], str]:\n return {}, \"a\"\n";
let diags = run(source)?;
assert!(
!codes(&diags).contains(&"returns_compatibility"),
"empty dict inside a returned tuple must be typed against dict[str, int], got: {:?}",
codes(&diags)
);
Ok(())
}

#[test]
fn tuple_literal_nested_in_returned_list_uses_declared_context(
) -> Result<(), Box<dyn std::error::Error>> {
// Refs #337. The propagation nests to arbitrary depth: the tuple context
// has to be reached through the enclosing list literal.
let source = "def f() -> list[tuple[list[int], str]]:\n return [([], \"a\")]\n";
let diags = run(source)?;
assert!(
!codes(&diags).contains(&"returns_compatibility"),
"tuple literal inside a returned list must be typed contextually, got: {:?}",
codes(&diags)
);
Ok(())
}

#[test]
fn empty_list_valid_for_homogeneous_tuple_return() -> Result<(), Box<dyn std::error::Error>> {
// Refs #337. The PEP 484 variable-length form `tuple[X, ...]` distributes
// the declared element type over every position of the literal.
let source = "def f() -> tuple[list[int], ...]:\n return [], []\n";
let diags = run(source)?;
assert!(
!codes(&diags).contains(&"returns_compatibility"),
"empty lists must be valid for a `tuple[list[int], ...]` return, got: {:?}",
codes(&diags)
);
Ok(())
}

#[test]
fn tuple_literal_with_wrong_element_still_errors() -> Result<(), Box<dyn std::error::Error>> {
// Contextual tuple typing is covariant per position, not a blanket pass: a
// genuine element mismatch inside the tuple must still fire.
let source = "def f() -> tuple[list[str], str]:\n return [1], \"a\"\n";
let diags = run(source)?;
assert!(
codes(&diags).contains(&"returns_compatibility"),
"`list[int]` literal in a `tuple[list[str], str]` return must still error, got: {:?}",
codes(&diags)
);
Ok(())
}

#[test]
fn homogeneous_tuple_literal_with_wrong_element_still_errors(
) -> Result<(), Box<dyn std::error::Error>> {
// The `tuple[X, ...]` arm must reject a position that does not fit `X`.
let source = "def f() -> tuple[int, ...]:\n return 1, \"a\"\n";
let diags = run(source)?;
assert!(
codes(&diags).contains(&"returns_compatibility"),
"a str element in a `tuple[int, ...]` return must still error, got: {:?}",
codes(&diags)
);
Ok(())
}
41 changes: 39 additions & 2 deletions crates/basilisk-checker/tests/config_override_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
//! entry over tag entry, strictest matching tag), the command partition
//! [CHKARCH-COMMANDS] (`pep` rules always run and can never be disabled;
//! everything else runs only when configuration decides it), and the
//! severity values of [CHKARCH-STRICTNESS-SEVERITY]. Code under test:
//! severity values of [CHKARCH-STRICTNESS-SEVERITY], and the unrun-rule count
//! behind [CHKARCH-CLI-SCOPE-NOTICE]. Code under test:
//! `basilisk-checker/src/lib.rs` (`EffectiveRuleConfig`,
//! `pep_disable_violations`, `is_pep_rule`).
//! `pep_disable_violations`, `analyze_selected_rules`, `is_pep_rule`).

use std::collections::HashMap;

Expand Down Expand Up @@ -251,6 +252,42 @@ fn pep_rule_disable_is_invalid_and_never_applied() {
);
}

/// [CHKARCH-CLI-SCOPE-NOTICE] (Refs #334): `analyze_selected_rules` reports
/// exactly the rules configuration selects that `check` will never evaluate —
/// the count a clean `check` run tells the user about.
#[test]
fn analyze_selected_rules_reports_what_check_will_not_run() {
assert!(
basilisk_checker::analyze_selected_rules(&BasiliskConfig::default()).is_empty(),
"a bare tree selects no analyze rule, so `check` hides nothing"
);

let tagged = config_with(&[], &[("basilisk", RuleSeverity::Error)]);
let selected = basilisk_checker::analyze_selected_rules(&tagged);
assert!(
selected.contains(&"BSK-0001"),
"one `basilisk` tag entry selects the house rules: {selected:?}"
);
assert!(
selected
.iter()
.all(|code| !basilisk_checker::is_pep_rule(code)),
"a `pep` rule always runs under check, so it is never reported unrun: {selected:?}"
);

let graded_pep = config_with(&[("imports_unresolved", RuleSeverity::Warning)], &[]);
assert!(
basilisk_checker::analyze_selected_rules(&graded_pep).is_empty(),
"grading a pep rule configures a rule `check` does run"
);

let disabled = config_with(&[("BSK-0001", RuleSeverity::Disabled)], &[]);
assert!(
!basilisk_checker::analyze_selected_rules(&disabled).contains(&"BSK-0001"),
"a disabled rule is not selected, so it is not something `check` hid"
);
}

/// [CHKARCH-COMMANDS]: the partition is the `pep` provenance tag.
#[test]
fn partition_is_the_pep_tag() {
Expand Down
5 changes: 5 additions & 0 deletions crates/basilisk-cli/src/fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ fn collect_and_fix(paths: &[String], allowed_rules: &[&str]) -> Result<FixSummar

let excluded = crate::pipeline::excluded_dirs_and_log(&config, &config_root);

// [CHKARCH-CONFIG-INCLUDE] (GitHub #333): a no-args run walks only the
// configured include roots, exactly like `check`/`analyze`. `fix` mutates
// files, so defaulting to the whole working directory would rewrite
// vendored sources (`venv/`) the user never asked it to touch.
let paths = &crate::pipeline::effective_check_paths(paths, &config, &config_root);
let python_files = crate::pipeline::collect_python_files(paths, &excluded)?;
let dir_configs = crate::pipeline::resolve_dir_configs(&python_files, &config);

Expand Down
Loading
Loading