diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..0e43efb --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,96 @@ +# Function definition × call catalog + +A **verdict-free**, tag-annotated enumeration of every distinct way a Python +function can be **defined** and **called**. This is the shared test-case corpus +for the argument lint rules this project will grow (enforce `*` on defs; prefer +keyword args at call sites; warn when an argument's variable name ≠ the parameter +name; auto-fix positional → keyword). + +Cases carry **dimension tags, not pass/fail verdicts**. A human reviews the +catalog and decides, per rule, which tag combinations should fire. This keeps one +corpus reusable across many rules. + +## Layout + +``` +catalog/ + _schema.py # Case dataclass + controlled vocabulary + validate() + __init__.py # all_cases() registry + README.md # this file — the authoring contract + param_kinds.py # signature shapes & counts (exemplar) + defaults_and_annotations.py # defaults (incl. mutable/sentinel) & annotations + callable_kinds.py # method/classmethod/static/lambda/async/property/ctor/partial/overload/decorated + arg_forms.py # per-argument literal vs name-match/mismatch, attr/expr/subscript, kw value forms + unpacking.py # *seq / **map / mixed / known vs opaque + arity_and_ordering.py # exact/too-few/too-many/duplicate/reorder/illegal-order + call_target_forms.py # bare/attr/super/subscript/alias/import/instantiation/self + source_and_inferability.py # stdlib/builtin/third-party/dynamic/uninferable/stub + cross_cutting.py # redundant kw, shadowing, reserved-ish names, prebound partial, overload dispatch +``` + +**One category file = one owner.** Files are disjoint and cases are appended, so +concurrent authors never collide. + +## Authoring rules + +1. Each module exposes `CASES: list[Case]`. +2. **ID prefix must equal the category** (e.g. `unpacking.*` only in `unpacking.py`). + Namespacing keeps IDs globally unique without coordination. +3. Every case must carry at minimum `call_target`, `source`, `inferability`, + plus the axes central to your category. +4. **Tags must come from the vocabulary** in `_schema.py`. Need a new value? + Add it to `VOCABULARY` first (coordinate — the vocabulary is the shared contract). +5. **No verdicts.** Never encode "this should warn/pass". Put reasoning in `notes`. +6. **Hold non-owned axes at a canonical value** to minimise cross-file duplication — + e.g. the arg-forms file keeps the signature to a plain `def function(parameter): ...` + unless the case is specifically about signature interplay. +7. Write snippets at column 0 inside the strings (the runner also `dedent`s defensively). + The **last line of `call`** is the call under test; the runner appends `#@`. +8. If a snippet is a deliberate **syntax error** (e.g. positional arg after keyword), + tag `validity: syntax_error`. If it parses but would raise at runtime (bad arity), + tag `validity: runtime_error`. Otherwise omit `validity` (defaults to `runnable`). + +## Worked example + +```python +from catalog._schema import Case + +CASES = [ + Case( + id="param_kinds.posonly.single.call_positional_literal", + definition="def function(position_only_parameter, /): ...", + call="function(None)", + tags={ + "definition_kind": ["posonly"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Positional-only param invoked positionally. A 'prefer keyword' rule " + "likely should NOT fire — keyword form is illegal for pos-only params. " + "Left for the human to decide per rule." + ), + ), +] +``` + +## Verification + +```shell +uv run pytest test/test_catalog.py +``` + +The runner asserts **structure only, never rule verdicts**: schema validity, +globally unique IDs, that every snippet parses via `astroid.extract_node` and +yields a `Call` (or fails to parse for `syntax_error` cases). Inferability and +plugin behaviour are *recorded, not asserted* (see the smoke test), so the corpus +stays verdict-free while regressions still surface. diff --git a/catalog/__init__.py b/catalog/__init__.py new file mode 100644 index 0000000..7376f5e --- /dev/null +++ b/catalog/__init__.py @@ -0,0 +1,33 @@ +"""Catalog of Python function definition x call cases. + +A verdict-free, tag-annotated enumeration of the ways a function can be defined +and called, used as the shared test-case corpus for the argument lint rules. +See ``catalog/README.md`` for the authoring contract. +""" + +from importlib import import_module +from typing import List + +from catalog._schema import Case, marked_source, validate, validity_of # noqa: F401 + +# One module per independent category. Each exposes ``CASES: list[Case]``. +CATEGORY_MODULES = [ + "param_kinds", + "defaults_and_annotations", + "callable_kinds", + "arg_forms", + "unpacking", + "arity_and_ordering", + "call_target_forms", + "source_and_inferability", + "cross_cutting", +] + + +def all_cases() -> List[Case]: + """Import every category module and return the concatenated list of cases.""" + cases: List[Case] = [] + for name in CATEGORY_MODULES: + module = import_module(f"catalog.{name}") + cases.extend(module.CASES) + return cases diff --git a/catalog/_schema.py b/catalog/_schema.py new file mode 100644 index 0000000..f119ee1 --- /dev/null +++ b/catalog/_schema.py @@ -0,0 +1,178 @@ +"""Shared schema and controlled vocabulary for the function def x call catalog. + +The catalog is a *verdict-free* enumeration of the ways a Python function can be +DEFINED and CALLED. Each :class:`Case` pairs a ``definition`` snippet with a +``call`` snippet and tags it along orthogonal dimensions. + +A human later decides, per prospective lint rule, which tag combinations should +raise a violation -- so cases carry NO pass/fail verdicts, only dimension tags +and free-text ``notes``. (A per-rule ``verdicts`` annotation may be layered on +later without changing this structure; see ``catalog/README.md``.) + +Every category module (``param_kinds.py``, ``unpacking.py``, ...) exposes a +module-level ``CASES: list[Case]``. IDs are namespaced with the category name so +they stay globally unique with zero coordination between parallel authors. +""" + +from __future__ import annotations + +import textwrap +from dataclasses import dataclass +from typing import List, Mapping, Sequence, Union + +TagValue = Union[str, Sequence[str]] + + +@dataclass(frozen=True) +class Case: + """One point in the definition x call space. + + Attributes: + id: Globally unique, category-namespaced, e.g. ``"unpacking.star_seq.known_len"``. + definition: Source that establishes the callable (``def``/``class``/``import``). + May be ``""`` when the callable is a builtin used directly (e.g. ``len``). + call: The call statement(s). May include helper lines (e.g. ``x = None``); the + *last* line must be the call under test -- the runner marks it with ``#@``. + tags: Mapping of axis -> value(s) drawn from :data:`VOCABULARY`. A value may be + a single string or a sequence of strings (for combinable axes). + notes: Reviewer-facing rationale / ambiguity. Never a verdict. + """ + + id: str + definition: str + call: str + tags: Mapping[str, TagValue] + notes: str = "" + + +# --- Controlled vocabulary ------------------------------------------------- +# axis -> set of permitted values. Some axes are single-valued, some combinable +# (a Case may list several). `validate` accepts either a str or a sequence. + +VOCABULARY: Mapping[str, frozenset] = { + # --- Definition axes --- + "definition_kind": frozenset({ + "posonly", "pos_or_kw", "var_positional", "bare_star", + "kw_only", "var_keyword", "no_params", + }), + "param_count": frozenset({"zero", "one", "few", "many", "boundary"}), + "defaults": frozenset({ + "no_default", "default_immutable", "default_mutable", + "default_callable", "default_sentinel", "default_expr", "default_on_kwonly", + }), + "annotations": frozenset({ + "unannotated", "annotated_simple", "annotated_complex", + "annotated_with_default", "return_annotation", + }), + "callable_kind": frozenset({ + "module_function", "instance_method", "classmethod", "staticmethod", + "nested_function", "lambda", "async_def", "generator", "async_generator", + "property_getter", "property_setter", "property_deleter", + "dunder_call", "class_constructor", "dataclass_init", "namedtuple_new", + "dunder_other", "abstractmethod", "functools_partial", + "functools_wraps_decorated", "typing_overload", + "signature_rewriting_decorator", "c_builtin", + }), + "decoration": frozenset({ + "undecorated", "decorator_transparent", "decorator_opaque", + "multiple_decorators", "parametrized_decorator", + }), + # --- Call axes --- + "arg_forms": frozenset({ + "pos_literal", "pos_var_name_match", "pos_var_name_mismatch", + "pos_attribute", "pos_subscript", "pos_call_result", "pos_expr", + "pos_literal_container", "kw", "kw_value_var_match", + "kw_value_var_mismatch", "kw_value_literal", "kw_value_attr_matches_key", + }), + "unpacking": frozenset({ + "none", "star_seq", "double_star_map", "mixed_unpacking", + "unpack_literal_known", "unpack_opaque", + }), + "arity": frozenset({ + "arity_exact", "arity_defaults_omitted", "arity_too_few", + "arity_too_many_positional", "arity_unexpected_keyword", + "arity_duplicate", "arity_absorbed_by_varargs", + "arity_absorbed_by_kwargs", "arity_unknown", + }), + "ordering": frozenset({ + "order_canonical", "order_kw_reordered", + "order_positional_then_keyword", "order_illegal_positional_after_keyword", + }), + "call_target": frozenset({ + "target_bare_name", "target_attribute", "target_super", + "target_subscript_result", "target_call_result", "target_alias", + "target_imported", "target_class_instantiation", "target_self_method", + "target_uninferable", + }), + # --- Environment / source axes --- + "source": frozenset({ + "same_module_editable", "same_project_editable", "stdlib_not_editable", + "third_party_not_editable", "c_builtin_no_signature", + "dynamically_generated", "stub_only", + }), + "inferability": frozenset({ + "inferable_full", "inferable_partial", "uninferable", + }), + # --- Optional: whether the snippet is expected to parse / run --- + # Absent => treated as "runnable". "syntax_error" cases are expected to + # FAIL to parse (e.g. positional arg after keyword); "runtime_error" cases + # parse fine but would raise TypeError when actually executed. + "validity": frozenset({"runnable", "runtime_error", "syntax_error"}), +} + +# Axes every case must carry -- the ones most rules branch on. +REQUIRED_AXES = frozenset({"call_target", "source", "inferability"}) + + +def validate(case: Case) -> List[str]: + """Return a list of human-readable problems with ``case`` (empty == valid).""" + errors: List[str] = [] + if not isinstance(case.id, str) or not case.id: + errors.append("id must be a non-empty string") + if not isinstance(case.definition, str): + errors.append(f"{case.id}: definition must be a string") + if not isinstance(case.call, str) or not case.call.strip(): + errors.append(f"{case.id}: call must be a non-empty string") + if not isinstance(case.tags, Mapping): + errors.append(f"{case.id}: tags must be a mapping") + return errors + + for axis, value in case.tags.items(): + if axis not in VOCABULARY: + errors.append(f"{case.id}: unknown axis {axis!r}") + continue + allowed = VOCABULARY[axis] + values = [value] if isinstance(value, str) else list(value) + for v in values: + if v not in allowed: + errors.append(f"{case.id}: {axis}={v!r} not in vocabulary") + + for axis in REQUIRED_AXES: + if axis not in case.tags: + errors.append(f"{case.id}: missing required axis {axis!r}") + + return errors + + +def validity_of(case: Case) -> str: + """Return the ``validity`` tag, defaulting to ``"runnable"``.""" + return str(case.tags.get("validity", "runnable")) + + +def marked_source(case: Case) -> str: + """Build the full snippet with astroid's ``#@`` marker on the call line. + + Mirrors the existing ``astroid.extract_node`` convention used in + ``test/tests.py``: definition + call, with the final (call) line marked so + ``extract_node`` returns the ``Call`` node. + """ + parts = [] + definition = textwrap.dedent(case.definition).strip("\n") + if definition.strip(): + parts.append(definition) + call = textwrap.dedent(case.call).strip("\n") + parts.append(call) + source = "\n".join(parts) + lines = source.split("\n") + lines[-1] = lines[-1] + " #@" + return "\n".join(lines) diff --git a/catalog/arg_forms.py b/catalog/arg_forms.py new file mode 100644 index 0000000..6e22e3f --- /dev/null +++ b/catalog/arg_forms.py @@ -0,0 +1,531 @@ +"""Category: per-argument forms at the call site (call axis C1). + +Varies how each argument is expressed: positional literal; positional variable +whose name MATCHES vs MISMATCHES the parameter name; attribute / subscript / +call-result / expression / literal-container as a positional arg; keyword arg; +keyword whose value-variable matches vs mismatches the key; keyword with a +literal value; keyword whose value attribute tail matches the key. Definition +held to a canonical `def function(parameter): ...` (or a small fixed signature) +unless the case is specifically about name matching across several params. + +This file is the heart of the motivating talk: argument-name vs parameter-name +matching. `rectangle(width, width)` silently passes the width value for the +height parameter, whereas `rectangle(width, height)` reads correctly. +""" + +from catalog._schema import Case + +CATEGORY = "arg_forms" + +_ENV = { + "source": "same_module_editable", + "inferability": "inferable_full", + "call_target": "target_bare_name", +} + +_DEF_ONE = "def function(parameter): ..." +_DEF_RECT = "def rectangle(width, height): ..." + +CASES = [ + # --- Positional literals --------------------------------------------- + Case( + id="arg_forms.pos_literal.none", + definition=_DEF_ONE, + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Bare literal passed positionally. No variable name exists to compare against the param.", + ), + Case( + id="arg_forms.pos_literal.int", + definition=_DEF_ONE, + call="function(1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Numeric literal positional. A name-match rule has nothing to key on; a 'prefer keyword' rule might.", + ), + + # --- Positional variable: name match vs mismatch --------------------- + Case( + id="arg_forms.pos_var_name_match.single", + definition=_DEF_ONE, + call="parameter = None\nfunction(parameter)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Variable name equals the parameter name. Redundant-but-safe: converting to keyword would just repeat the name.", + ), + Case( + id="arg_forms.pos_var_name_mismatch.single", + definition=_DEF_ONE, + call="argument = None\nfunction(argument)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Variable name differs from the parameter name. Common and usually fine, but the signal a name-match rule watches.", + ), + Case( + id="arg_forms.pos_var_name_match.rectangle_both", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(width, height)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="The talk's correct case: both variables' names line up with their positions. Nothing should warn.", + ), + Case( + id="arg_forms.pos_var_name_mismatch.rectangle_width_width", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(width, width)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match", "pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes=( + "The talk's smoking gun: rectangle(width, width). Arg 1 (width->width) matches; " + "arg 2 (width->height) mismatches -- 'width' passed where 'height' is expected. " + "Likely a bug the name-match rule should surface." + ), + ), + Case( + id="arg_forms.pos_var_name_mismatch.rectangle_swapped", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(height, width)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Both positions swapped: height->width and width->height. Every argument's name mismatches its parameter.", + ), + Case( + id="arg_forms.pos_var_name_mismatch.rectangle_unrelated", + definition=_DEF_RECT, + call="a = 1\nb = 2\nrectangle(a, b)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Short/unrelated variable names (a, b). Names give no evidence either way -- a name-match rule cannot decide; noted as neutral.", + ), + + # --- Positional non-name expressions --------------------------------- + Case( + id="arg_forms.pos_attribute", + definition=_DEF_ONE, + call="obj = object()\nfunction(obj.attr)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_attribute"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Attribute access. Its tail ('attr') could be compared to the param name by a lenient rule.", + ), + Case( + id="arg_forms.pos_attribute.tail_matches", + definition=_DEF_RECT, + call="obj = object()\nrectangle(obj.width, obj.height)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_attribute"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Attribute tails (width/height) align with parameters. Whether the tail counts as a 'name match' is a rule choice.", + ), + Case( + id="arg_forms.pos_subscript", + definition=_DEF_ONE, + call='d = {}\nfunction(d["k"])', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_subscript"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Subscript expression. No simple identifier to name-match against the parameter.", + ), + Case( + id="arg_forms.pos_call_result", + definition=_DEF_ONE, + call="def g(): return None\nfunction(g())", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_call_result"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Result of a nested call passed positionally. No binding name to compare.", + ), + Case( + id="arg_forms.pos_expr.binop", + definition=_DEF_ONE, + call="a = 1\nb = 2\nfunction(a + b)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Arithmetic expression argument. Compound expression, no single name to match.", + ), + Case( + id="arg_forms.pos_expr.ternary", + definition=_DEF_ONE, + call="c = True\nx = 1\ny = 2\nfunction(x if c else y)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Conditional (ternary) expression argument. Not a plain name.", + ), + Case( + id="arg_forms.pos_literal_container.list", + definition=_DEF_ONE, + call="function([1, 2])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="List display literal passed positionally.", + ), + Case( + id="arg_forms.pos_literal_container.dict", + definition=_DEF_ONE, + call='function({"a": 1})', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Dict display literal passed positionally (not **-unpacked).", + ), + + # --- Keyword forms ---------------------------------------------------- + Case( + id="arg_forms.kw.none", + definition=_DEF_ONE, + call="function(parameter=None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Keyword argument with a literal value. The end-state a 'prefer keyword' auto-fix would produce.", + ), + Case( + id="arg_forms.kw_value_literal.int", + definition=_DEF_ONE, + call="function(parameter=1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Keyword with numeric literal value. Key names the param explicitly; value is a literal.", + ), + Case( + id="arg_forms.kw_value_var_match.single", + definition=_DEF_ONE, + call="parameter = None\nfunction(parameter=parameter)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Keyword whose value variable equals the key (parameter=parameter). Redundant naming; a candidate for a 'use shorthand' suggestion.", + ), + Case( + id="arg_forms.kw_value_var_mismatch.single", + definition=_DEF_ONE, + call="argument = None\nfunction(parameter=argument)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Keyword whose value variable differs from the key. Explicit and unambiguous -- the key pins the intent.", + ), + Case( + id="arg_forms.kw_value_var_match.rectangle_both", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(width=width, height=height)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Both keywords use matching value variables. Maximally explicit; the safest form the talk endorses.", + ), + Case( + id="arg_forms.kw_value_var_match.rectangle_reordered", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(height=height, width=width)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_kw_reordered", + **_ENV, + }, + notes="The talk's reordered-keywords case: keywords given height-first. Order differs from the signature but binding is unambiguous and correct.", + ), + Case( + id="arg_forms.kw_value_var_mismatch.rectangle_crossed", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(width=height, height=width)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Keys correct but values crossed (width=height, height=width). Explicit keys make it legal, yet the value names hint at a possible swap bug.", + ), + + # --- Mixed positional + keyword (the talk's mixed scenario) ----------- + Case( + id="arg_forms.mixed.rectangle_pos_then_kw_match", + definition=_DEF_RECT, + call="width = 1\nheight = 2\nrectangle(width, height=height)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match", "kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + **_ENV, + }, + notes="Mixed form from the talk: rectangle(width, height=height). First positional name matches; second is an explicit matching keyword.", + ), + Case( + id="arg_forms.mixed.rectangle_pos_then_kw_mismatch", + definition=_DEF_RECT, + call="width = 1\nrectangle(width, height=width)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match", "kw", "kw_value_var_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + **_ENV, + }, + notes="Mixed with a mismatched keyword value: rectangle(width, height=width). Positional matches; the keyword passes 'width' into 'height'.", + ), + + # --- Keyword value whose attribute tail matches the key --------------- + Case( + id="arg_forms.kw_value_attr_matches_key", + definition="def connect(timeout): ...", + call="self = object()\nconnect(timeout=self.timeout)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_attr_matches_key"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Common idiom connect(timeout=self.timeout): the attribute tail equals the keyword. A shorthand/redundancy rule may treat this as a name match.", + ), + Case( + id="arg_forms.kw_value_attr_matches_key.rectangle_both", + definition=_DEF_RECT, + call="self = object()\nrectangle(width=self.width, height=self.height)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_attr_matches_key"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + **_ENV, + }, + notes="Both keyword values are self. attributes whose tails match the keys -- a very common object-copy idiom.", + ), +] diff --git a/catalog/arity_and_ordering.py b/catalog/arity_and_ordering.py new file mode 100644 index 0000000..0cca5f9 --- /dev/null +++ b/catalog/arity_and_ordering.py @@ -0,0 +1,557 @@ +"""Category: arity & argument ordering (call axes C3/C4). + +Varies argument count relative to the signature (exact, defaults-omitted, +too-few, too-many-positional, unexpected-keyword, duplicate positional+keyword, +absorbed-by-varargs/kwargs) and ordering (canonical, keywords reordered, +positional-then-keyword, illegal positional-after-keyword). Runtime-error cases +tag `validity: runtime_error`; syntax-error cases tag `validity: syntax_error` +and are kept genuinely unparseable so the harness's "expected to fail to parse" +check holds. + +Signatures are held to canonical shapes -- `def function(a, b, c=3): ...` plus a +few `*args` / `**kwargs` / `/` / `*` variants where the axis under test demands +them -- so this file isolates arity and ordering rather than signature shape +(owned by `param_kinds.py`). +""" + +from catalog._schema import Case + +CATEGORY = "arity_and_ordering" + +CASES = [ + # --- arity: exact ------------------------------------------------------ + Case( + id="arity_and_ordering.arity_exact.all_positional", + definition="def function(a, b, c=3): ...", + call="function(1, 2, 3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Baseline: every parameter (including the defaulted one) supplied positionally, in order.", + ), + # --- arity: defaults omitted (valid) ----------------------------------- + Case( + id="arity_and_ordering.arity_defaults_omitted.trailing_default", + definition="def function(a, b, c=3): ...", + call="function(1, 2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="`c` omitted and covered by its default. Valid; fewer args than params but no required param missing.", + ), + Case( + id="arity_and_ordering.arity_defaults_omitted.two_defaults", + definition="def function(a, b=2, c=3): ...", + call="function(1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Only the single required param supplied; both defaults left to fill in. Valid.", + ), + # --- arity: too few (runtime_error) ------------------------------------ + Case( + id="arity_and_ordering.arity_too_few.missing_required", + definition="def function(a, b, c=3): ...", + call="function(1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_few", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Required param `b` never supplied -> TypeError: missing 1 required positional argument.", + ), + Case( + id="arity_and_ordering.arity_too_few.none_supplied", + definition="def function(a, b, c=3): ...", + call="function()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_too_few", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="No arguments at all; both required params `a` and `b` missing -> TypeError.", + ), + Case( + id="arity_and_ordering.arity_too_few.kwonly_missing", + definition="def function(a, *, b): ...", + call="function(1)", + tags={ + "definition_kind": ["pos_or_kw", "bare_star", "kw_only"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_few", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Required keyword-only param `b` omitted -> TypeError: missing 1 required keyword-only argument.", + ), + # --- arity: too many positional (runtime_error) ------------------------ + Case( + id="arity_and_ordering.arity_too_many_positional.one_extra", + definition="def function(a, b, c=3): ...", + call="function(1, 2, 3, 4)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_many_positional", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Four positionals for three params and no `*args` -> TypeError: takes 3 positional arguments but 4 given.", + ), + Case( + id="arity_and_ordering.arity_too_many_positional.no_params", + definition="def function(): ...", + call="function(1)", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_many_positional", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Zero-parameter function given one positional -> TypeError: takes 0 positional arguments but 1 given.", + ), + # --- arity: unexpected keyword (runtime_error) ------------------------- + Case( + id="arity_and_ordering.arity_unexpected_keyword.nonexistent", + definition="def function(a, b, c=3): ...", + call="function(1, 2, nonexistent=1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_unexpected_keyword", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Keyword `nonexistent` matches no param and there is no `**kwargs` -> TypeError: unexpected keyword argument.", + ), + Case( + id="arity_and_ordering.arity_unexpected_keyword.only_unexpected", + definition="def function(): ...", + call="function(nonexistent=1)", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_unexpected_keyword", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="Sole argument is an unknown keyword to a no-param function -> TypeError.", + ), + # --- arity: duplicate (runtime_error) ---------------------------------- + Case( + id="arity_and_ordering.arity_duplicate.positional_and_keyword", + definition="def function(a, b, c=3): ...", + call="function(1, 2, a=10)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_duplicate", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="`a` supplied positionally (as 1) and again by keyword -> TypeError: got multiple values for argument 'a'.", + ), + Case( + id="arity_and_ordering.arity_duplicate.first_positional", + definition="def function(a, b, c=3): ...", + call="function(1, b=2, a=10)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_duplicate", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes="First positional binds `a`; then `a=10` re-supplies it -> TypeError: multiple values for argument 'a'.", + ), + # --- arity: absorbed by *args ------------------------------------------ + Case( + id="arity_and_ordering.arity_absorbed_by_varargs.extra_positionals", + definition="def function(a, b, *args): ...", + call="function(1, 2, 3, 4, 5)", + tags={ + "definition_kind": ["pos_or_kw", "var_positional"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Surplus positionals (3,4,5) collected by `*args`. Valid; no fixed cap on positional count.", + ), + # --- arity: absorbed by **kwargs --------------------------------------- + Case( + id="arity_and_ordering.arity_absorbed_by_kwargs.extra_keyword", + definition="def function(a, b, **kwargs): ...", + call="function(1, 2, extra=3, more=4)", + tags={ + "definition_kind": ["pos_or_kw", "var_keyword"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Otherwise-unexpected keywords `extra`/`more` collected by `**kwargs`. Valid.", + ), + # --- posonly by keyword: absorbed by **kwargs (valid) ------------------ + Case( + id="arity_and_ordering.arity_absorbed_by_kwargs.posonly_name_via_kwargs", + definition="def function(a, /, **kwargs): ...", + call="function(1, a=2)", + tags={ + "definition_kind": ["posonly", "var_keyword"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes=( + "`a` is positional-only, so `a=2` does NOT bind the param; it lands in " + "`**kwargs` under key 'a'. Positional 1 binds the real param. Valid, and a " + "classic false-duplicate trap for naive rules." + ), + ), + # --- posonly by keyword: no **kwargs (runtime_error) ------------------- + Case( + id="arity_and_ordering.arity_unexpected_keyword.posonly_name_no_kwargs", + definition="def function(a, /): ...", + call="function(a=1)", + tags={ + "definition_kind": ["posonly"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_unexpected_keyword", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "Positional-only `a` called by keyword with no `**kwargs` to catch it -> " + "TypeError: got some positional-only arguments passed as keyword arguments." + ), + ), + # --- ordering: canonical ----------------------------------------------- + Case( + id="arity_and_ordering.order_canonical.positional_in_order", + definition="def function(a, b, c=3): ...", + call="function(1, 2, 3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="All positional, definition order. The reference ordering other cases contrast against.", + ), + # --- ordering: keywords reordered (legal) ------------------------------ + Case( + id="arity_and_ordering.order_kw_reordered.all_keyword", + definition="def function(a, b, c=3): ...", + call="function(c=3, a=1, b=2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_kw_reordered", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="All params passed by keyword but out of definition order. Legal; binding is by name, not position.", + ), + Case( + id="arity_and_ordering.order_kw_reordered.positional_then_reordered_kw", + definition="def function(a, b, c=3): ...", + call="function(1, c=3, b=2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_kw_reordered", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Leading positional binds `a`; trailing keywords `c`,`b` are out of order but legal.", + ), + # --- ordering: positional then keyword (normal) ------------------------ + Case( + id="arity_and_ordering.order_positional_then_keyword.mixed", + definition="def function(a, b, c=3): ...", + call="function(1, 2, c=3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runnable", + }, + notes="Standard idiom: leading positionals then trailing keyword(s), all in order. Valid.", + ), + # --- ordering: illegal positional after keyword (syntax_error) --------- + Case( + id="arity_and_ordering.order_illegal_positional_after_keyword.basic", + definition="def function(a, b, c=3): ...", + call="function(a=1, 2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_illegal_positional_after_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "syntax_error", + }, + notes="Positional `2` after keyword `a=1` is a SyntaxError; the snippet must fail to parse.", + ), + Case( + id="arity_and_ordering.order_illegal_positional_after_keyword.with_star_unpack", + definition="def function(a, b, *args): ...", + call="function(a=1, *args, 2)", + tags={ + "definition_kind": ["pos_or_kw", "var_positional"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "star_seq", + "arity": "arity_unknown", + "ordering": "order_illegal_positional_after_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + "validity": "syntax_error", + }, + notes=( + "Bare positional `2` after keyword `a=1` is a SyntaxError -- 'positional " + "argument follows keyword argument' -- even with a `*args` unpack in between. " + "(Note `f(*args, 1)` alone is legal, so it is the trailing bare positional " + "after a keyword that makes this unparseable.)" + ), + ), + Case( + id="arity_and_ordering.order_illegal_positional_after_double_star", + definition="def function(**kwargs): ...", + call="function(**kwargs, 1)", + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "double_star_map", + "arity": "arity_unknown", + "ordering": "order_illegal_positional_after_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + "validity": "syntax_error", + }, + notes="Positional argument after `**kwargs` unpack is a SyntaxError; snippet must fail to parse.", + ), + # --- arity: unknown (uninferable target) ------------------------------- + Case( + id="arity_and_ordering.arity_unknown.uninferable_target", + definition="import random\nfunction = random.choice([len, str, hex])", + call="function(1, 2, 3)", + tags={ + "definition_kind": ["no_params"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_uninferable", + "source": "same_module_editable", + "inferability": "uninferable", + "validity": "runnable", + }, + notes=( + "Target resolves to one of several callables chosen at runtime; the signature " + "is not statically knowable, so arity cannot be checked. Parses fine; whether " + "it TypeErrors depends on the runtime pick." + ), + ), +] diff --git a/catalog/call_target_forms.py b/catalog/call_target_forms.py new file mode 100644 index 0000000..a63a326 --- /dev/null +++ b/catalog/call_target_forms.py @@ -0,0 +1,695 @@ +"""Category: call-target reference forms (call axis C5). + +Varies how the callable is referenced at the call site: bare name, attribute +(`mod.f` / `obj.method`), `super().method()`, subscript result +(`handlers["k"]()`), call result (`factory()()`), alias/rebinding +(`g = f; g()`), imported name, class instantiation (`Class()`), +`self.method()`, and uninferable targets. Definition held to a canonical form. + +This file isolates HOW THE CALLABLE IS REFERENCED, not the argument form: the +argument is kept canonical (a single positional literal or a single keyword) +throughout. The interesting axis is `call_target`, and — because the reference +form determines whether a deep-analysis rule can walk back to the signature — +`inferability` co-varies (recorded in tags + `notes`, never as a verdict). +""" + +from catalog._schema import Case + +CATEGORY = "call_target_forms" + +CASES = [ + # --- target_bare_name -------------------------------------------------- + Case( + id="call_target_forms.bare_name.module_function", + definition="def function(parameter): ...", + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Baseline reference form: a bare module-level name resolves directly " + "to its `def`, so the signature is fully inferable from the call site." + ), + ), + Case( + id="call_target_forms.bare_name.local_nested", + definition=( + "def outer():\n" + " def function(parameter): ...\n" + " return function(None)" + ), + call="outer()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "nested_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The call under test is `outer()`; the nested `function(None)` shows a " + "bare name resolving to a lexically-enclosed def. Inferable within scope, " + "but note the marked Call is the outer bare name, not the inner one." + ), + ), + # --- target_attribute -------------------------------------------------- + Case( + id="call_target_forms.attribute.imported_module", + definition="import mymodule", + call="mymodule.function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_project_editable", + "inferability": "inferable_partial", + }, + notes=( + "Attribute access on an imported module (`module.func`). Astroid can " + "usually resolve the module and the attribute if the module is on the " + "path; recorded as partial because it depends on the module being " + "importable/analysable at lint time." + ), + ), + Case( + id="call_target_forms.attribute.instance_method", + definition=( + "class Widget:\n" + " def method(self, parameter): ...\n" + "obj = Widget()" + ), + call="obj.method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Attribute access on an instance (`obj.method`). Astroid infers the type " + "of `obj` and resolves the bound method; `self` is dropped, so the rule " + "must offset the signature by one when matching the single argument." + ), + ), + Case( + id="call_target_forms.attribute.chained", + definition=( + "class Inner:\n" + " def method(self, parameter): ...\n" + "class Outer:\n" + " def __init__(self):\n" + " self.inner = Inner()\n" + "obj = Outer()" + ), + call="obj.inner.method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Multi-hop attribute chain (`obj.inner.method`). Inference requires " + "resolving each hop's type (Outer -> attribute inner -> Inner -> method); " + "partial because attribute-assignment inference is more fragile than a " + "direct instance attribute lookup." + ), + ), + # --- target_super ------------------------------------------------------ + Case( + id="call_target_forms.super.zero_arg", + definition=( + "class Base:\n" + " def method(self, parameter): ...\n" + "class Derived(Base):\n" + " def method(self, parameter):\n" + " return super().method(parameter)" + ), + call="Derived().method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_var_name_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_super", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`super().method(parameter)` inside `Derived.method`. The marked Call is " + "the outer `Derived().method(None)` driver, but the case exercises the " + "super-proxy form; astroid resolves super() to the MRO and finds " + "Base.method's signature. The internal super call passes the parameter " + "by matching name." + ), + ), + Case( + id="call_target_forms.super.explicit_args", + definition=( + "class Base:\n" + " def method(self, parameter): ...\n" + "class Derived(Base):\n" + " def method(self, parameter):\n" + " return super(Derived, self).method(parameter)" + ), + call="super(Derived, self).method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_super", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Two-argument `super(Derived, self)` form used as the call target. " + "`self` is a free name at module scope in the marked snippet, so the " + "receiver is not resolvable there; inference of the proxied Base.method " + "signature is partial in isolation." + ), + ), + # --- target_subscript_result ------------------------------------------- + Case( + id="call_target_forms.subscript_result.dict_of_functions", + definition=( + "def function(parameter): ...\n" + "handlers = {\"k\": function}" + ), + call="handlers[\"k\"](None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_subscript_result", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "The callable is the result of subscripting a dict literal of functions. " + "Astroid can sometimes infer the value at a constant string key back to " + "`function`, but subscript inference is best-effort; recorded partial." + ), + ), + Case( + id="call_target_forms.subscript_result.list_index", + definition=( + "def function(parameter): ...\n" + "callbacks = [function]" + ), + call="callbacks[0](None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_subscript_result", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Callable obtained by integer-indexing a list literal. Like the dict " + "case, astroid may infer the element back to `function`; partial because " + "list-element inference degrades with any mutation of the list." + ), + ), + Case( + id="call_target_forms.subscript_result.opaque_key", + definition=( + "def function(parameter): ...\n" + "handlers = {}\n" + "handlers[compute_key()] = function" + ), + call="handlers[compute_key()](None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_subscript_result", + "source": "same_module_editable", + "inferability": "uninferable", + }, + notes=( + "Subscript with a dynamic (call-result) key into a dict populated at " + "runtime. `compute_key` is undefined and the key is non-constant, so the " + "target callable cannot be resolved; arity is therefore unknown." + ), + ), + # --- target_call_result ------------------------------------------------ + Case( + id="call_target_forms.call_result.factory", + definition=( + "def function(parameter): ...\n" + "def factory():\n" + " return function" + ), + call="factory()(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_call_result", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "The callable is the return value of another call (`factory()(...)`). " + "Astroid can infer the return of a simple factory that returns a known " + "function, so the inner signature is sometimes recoverable; partial " + "because it hinges on return-value inference." + ), + ), + Case( + id="call_target_forms.call_result.decorator_factory", + definition=( + "def make_multiplier():\n" + " def function(parameter): ...\n" + " return function" + ), + call="make_multiplier()(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "nested_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_call_result", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Closure factory: the returned callable is a nested function defined " + "inside the factory. Signature recoverable via return inference into the " + "nested def; partial because closures over factory state can confound it." + ), + ), + # --- target_alias ------------------------------------------------------ + Case( + id="call_target_forms.alias.local_rebind", + definition=( + "def function(parameter): ...\n" + "g = function" + ), + call="g(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_alias", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Simple rebinding `g = function; g(...)`. Astroid follows the assignment " + "to the original def, so the signature is fully inferable through the " + "alias." + ), + ), + Case( + id="call_target_forms.alias.import_as", + definition="from mymodule import function as g", + call="g(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_alias", + "source": "same_project_editable", + "inferability": "inferable_partial", + }, + notes=( + "`from mod import func as g; g(...)`. The alias is an import-time rename; " + "astroid resolves the aliased name to the source def if the module is " + "analysable. Partial because it depends on the import being resolvable." + ), + ), + Case( + id="call_target_forms.alias.conditional_rebind", + definition=( + "def function(parameter): ...\n" + "def other(parameter): ...\n" + "import random\n" + "g = function if random.random() > 0.5 else other" + ), + call="g(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_alias", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Alias bound to one of two functions via a conditional expression. " + "Astroid infers a union of both defs; since both share the same " + "signature the arity is consistent, but the target is ambiguous — " + "partial inference (multiple candidates)." + ), + ), + # --- target_imported --------------------------------------------------- + Case( + id="call_target_forms.imported.stdlib_from_import", + definition="from os.path import join", + call="join(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "stdlib_not_editable", + "inferability": "inferable_partial", + }, + notes=( + "Imported stdlib name used bare (`from os.path import join`). The def " + "lives in another module; astroid resolves it via its stdlib " + "definition/typeshed. arity_unknown because os.path.join's true " + "signature is `join(a, *p)`, so a single positional is legal but overall " + "arity depends on the variadic tail." + ), + ), + Case( + id="call_target_forms.imported.thirdparty_attribute", + definition="import third_party_lib", + call="third_party_lib.function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "third_party_not_editable", + "inferability": "inferable_partial", + }, + notes=( + "Imported third-party name accessed as an attribute. Both a " + "target_imported and target_attribute flavour; classified as imported " + "because the source module is external and not editable. Inferability " + "depends on the package being installed/analysable — partial." + ), + ), + # --- target_class_instantiation ---------------------------------------- + Case( + id="call_target_forms.class_instantiation.init", + definition=( + "class Widget:\n" + " def __init__(self, parameter): ..." + ), + call="Widget(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Calling a class name instantiates it; the effective signature is that " + "of `__init__` with `self` dropped. Fully inferable — astroid maps the " + "class-name call to the __init__ params." + ), + ), + Case( + id="call_target_forms.class_instantiation.new", + definition=( + "class Widget:\n" + " def __new__(cls, parameter):\n" + " return super().__new__(cls)" + ), + call="Widget(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Instantiation where the effective signature comes from `__new__` (no " + "`__init__`). A rule matching call args must pick __new__ here; astroid " + "handles this less reliably than __init__, so partial." + ), + ), + # --- target_self_method ------------------------------------------------ + Case( + id="call_target_forms.self_method.instance", + definition=( + "class Widget:\n" + " def helper(self, parameter): ...\n" + " def run(self):\n" + " return self.helper(None)" + ), + call="Widget().run()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_self_method", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`self.helper(None)` inside a method body. The marked Call is the driver " + "`Widget().run()`; the case exercises the self-method form. Astroid " + "infers `self`'s class and resolves the sibling method — fully inferable." + ), + ), + Case( + id="call_target_forms.self_method.cls_classmethod", + definition=( + "class Widget:\n" + " @classmethod\n" + " def build(cls, parameter): ...\n" + " @classmethod\n" + " def run(cls):\n" + " return cls.build(None)" + ), + call="Widget.run()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "classmethod", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_self_method", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`cls.build(None)` inside a classmethod — the classmethod analogue of " + "the self-method form. `cls` binds to the class; `cls` is dropped from " + "the effective signature like `self`. Marked Call is the `Widget.run()` " + "driver." + ), + ), + # --- target_uninferable ------------------------------------------------ + Case( + id="call_target_forms.uninferable.getattr_dynamic", + definition="obj = object()", + call="getattr(obj, name)(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_uninferable", + "source": "dynamically_generated", + "inferability": "uninferable", + }, + notes=( + "`getattr(obj, name)(...)` with a dynamic attribute name. The target is " + "chosen at runtime and cannot be resolved statically; no signature is " + "available, so any arg-shape rule must abstain. arity_unknown." + ), + ), + Case( + id="call_target_forms.uninferable.opaque_return", + definition="import external_registry", + call="external_registry.lookup()(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_uninferable", + "source": "dynamically_generated", + "inferability": "uninferable", + }, + notes=( + "Callable is the return of an opaque external call " + "(`external_registry.lookup()`). The returned object's type/signature is " + "unknown, so the target is uninferable — a target_call_result whose " + "inner callable cannot be recovered." + ), + ), + Case( + id="call_target_forms.uninferable.parameter_callback", + definition=( + "def run(callback):\n" + " return callback(None)" + ), + call="run(print)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_uninferable", + "source": "same_module_editable", + "inferability": "uninferable", + }, + notes=( + "Inside `run`, `callback(None)` calls a parameter whose value is only " + "known at the call site. From the definition alone the callee is " + "uninferable; the marked Call `run(print)` shows a concrete binding, but " + "the reference form under study (calling a bare parameter) is generically " + "unresolvable." + ), + ), +] diff --git a/catalog/callable_kinds.py b/catalog/callable_kinds.py new file mode 100644 index 0000000..fdfffc5 --- /dev/null +++ b/catalog/callable_kinds.py @@ -0,0 +1,868 @@ +"""Category: callable kinds & decoration (definition axes D5/D6). + +Varies what *kind* of callable the definition is: module function, instance +method, classmethod, staticmethod, nested/closure, lambda, async def, generator, +property getter/setter, __call__ object, class constructor, dataclass __init__, +namedtuple, abstractmethod, functools.partial, functools.wraps-decorated, +typing.overload, signature-rewriting decorators. Also decoration variants. + +Non-central axes (signature shape, arg forms, arity, ordering) are held to a +canonical minimal form -- a single positional-or-keyword parameter passed one +positional literal -- so this file isolates the *callable kind*. + +Edge note on invocation-vs-call: properties and subscript/operator dunders are +triggered by attribute access / operators / subscription, none of which produce +an astroid ``Call`` node. Those cases invoke the underlying function object +directly (e.g. the setter function, or ``obj.__getitem__(k)``) so the last line +is a genuine ``Call``; the limitation is documented per-case in ``notes``. +""" + +from catalog._schema import Case + +CATEGORY = "callable_kinds" + +CASES = [ + Case( + id="callable_kinds.module_function", + definition="def function(parameter):\n return parameter", + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Baseline callable kind: a plain module-level function.", + ), + Case( + id="callable_kinds.instance_method.via_instance", + definition=( + "class Class:\n" + " def method(self, parameter):\n" + " return parameter" + ), + call="Class().method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Instance method called via an instance. `self` is bound implicitly, " + "so the single explicit argument maps to `parameter`." + ), + ), + Case( + id="callable_kinds.instance_method.via_self", + definition=( + "class Class:\n" + " def method(self, parameter):\n" + " return parameter\n" + " def caller(self):\n" + " return self.method(None)" + ), + call="Class().caller()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_self_method", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Instance method invoked as `self.method(...)` from within another " + "method. The last-line Call is `Class().caller()` (the outer trigger); " + "the `self.method(None)` call it wraps is the target of interest -- a " + "rule walking the body sees the self-bound call form." + ), + ), + Case( + id="callable_kinds.classmethod", + definition=( + "class Class:\n" + " @classmethod\n" + " def method(cls, parameter):\n" + " return parameter" + ), + call="Class.method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "classmethod", + "decoration": "decorator_transparent", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@classmethod binds `cls` implicitly. Called on the class; the explicit " + "argument maps to `parameter`. astroid models the descriptor transparently." + ), + ), + Case( + id="callable_kinds.staticmethod", + definition=( + "class Class:\n" + " @staticmethod\n" + " def method(parameter):\n" + " return parameter" + ), + call="Class.method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "staticmethod", + "decoration": "decorator_transparent", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@staticmethod: no implicit first parameter, so the signature maps 1:1 " + "with the call, unlike instance/class methods." + ), + ), + Case( + id="callable_kinds.nested_function", + definition=( + "def outer():\n" + " def inner(parameter):\n" + " return parameter\n" + " return inner(None)" + ), + call="outer()", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "nested_function", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Nested function (closure). The inner call `inner(None)` is the target; " + "the last-line Call `outer()` is the enclosing trigger since the nested " + "def is only reachable inside `outer`." + ), + ), + Case( + id="callable_kinds.closure_returned", + definition=( + "def make(captured):\n" + " def inner(parameter):\n" + " return captured + parameter\n" + " return inner" + ), + call="make(1)(2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "nested_function", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_call_result", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Closure returned and immediately called: `make(1)(2)`. The outer Call " + "result is the callable, so the target is a call-result, and the " + "returned closure captures `captured`." + ), + ), + Case( + id="callable_kinds.lambda", + definition="function = lambda parameter: parameter", + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "lambda", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Lambda bound to a name. Lambdas cannot have annotations and their " + "params are always positional-or-keyword; still callable by keyword." + ), + ), + Case( + id="callable_kinds.async_def", + definition=( + "async def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "async_def", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Async function. Calling it returns a coroutine (not awaited here); the " + "argument-binding semantics are identical to a sync def for lint purposes." + ), + ), + Case( + id="callable_kinds.generator", + definition=( + "def function(parameter):\n" + " yield parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "generator", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Generator function (contains `yield`). Calling it builds a generator " + "object; argument binding is ordinary, only the return semantics differ." + ), + ), + Case( + id="callable_kinds.async_generator", + definition=( + "async def function(parameter):\n" + " yield parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "async_generator", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Async generator (`async def` + `yield`). Returns an async-generator " + "object; argument binding is ordinary." + ), + ), + Case( + id="callable_kinds.property_getter", + definition=( + "class Class:\n" + " @property\n" + " def value(self):\n" + " return None" + ), + call="Class().value.__get__(Class(), Class)", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "property_getter", + "decoration": "decorator_transparent", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "INVOCATION-VS-CALL LIMITATION: a property getter is triggered by " + "attribute access (`obj.value`), which is an astroid `Attribute`, not a " + "`Call`. To yield a real Call we invoke the descriptor's `__get__` " + "directly. A rule targeting the *user-facing* getter would never see a " + "Call at all -- documented here as the closest call form." + ), + ), + Case( + id="callable_kinds.property_setter", + definition=( + "class Class:\n" + " @property\n" + " def value(self):\n" + " return self._value\n" + " @value.setter\n" + " def value(self, parameter):\n" + " self._value = parameter" + ), + call="Class.value.fset(Class(), None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "property_setter", + "decoration": "parametrized_decorator", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "INVOCATION-VS-CALL LIMITATION: a setter fires on assignment " + "(`obj.value = x`), which is an `Assign`, never a `Call`. We call the " + "underlying function via the property's `fset` slot to get a genuine " + "Call. `@value.setter` is a decorator produced by another descriptor, " + "tagged parametrized_decorator as the closest fit." + ), + ), + Case( + id="callable_kinds.property_deleter", + definition=( + "class Class:\n" + " @property\n" + " def value(self):\n" + " return self._value\n" + " @value.deleter\n" + " def value(self):\n" + " del self._value" + ), + call="Class.value.fdel(Class())", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "property_deleter", + "decoration": "parametrized_decorator", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "INVOCATION-VS-CALL LIMITATION: a deleter fires on `del obj.value`, a " + "`Delete` node, never a `Call`. We invoke the underlying function via " + "the property's `fdel` slot. Only `self` is bound, no other args." + ), + ), + Case( + id="callable_kinds.dunder_call", + definition=( + "class Class:\n" + " def __call__(self, parameter):\n" + " return parameter\n" + "instance = Class()" + ), + call="instance(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "dunder_call", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Callable instance via `__call__`. Call site `instance(None)` looks like " + "a plain function call, but resolution routes through the class's " + "`__call__`; `self` is bound implicitly." + ), + ), + Case( + id="callable_kinds.class_constructor", + definition=( + "class Class:\n" + " def __init__(self, parameter):\n" + " self.parameter = parameter" + ), + call="Class(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Class instantiation `Class(None)` dispatches to `__init__`; the call " + "site's arguments bind to `__init__` params (minus `self`)." + ), + ), + Case( + id="callable_kinds.dataclass_init", + definition=( + "from dataclasses import dataclass\n" + "@dataclass\n" + "class Class:\n" + " parameter: int" + ), + call="Class(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "annotated_simple", + "callable_kind": "dataclass_init", + "decoration": "decorator_transparent", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@dataclass synthesizes `__init__` from annotated class attributes. The " + "signature is generated, not written -- a rule must derive params from " + "the field annotations. astroid has a dataclass brain for this." + ), + ), + Case( + id="callable_kinds.namedtuple_new", + definition=( + "from collections import namedtuple\n" + "Class = namedtuple('Class', ['parameter'])" + ), + call="Class(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "namedtuple_new", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "namedtuple factory. Construction dispatches to a synthesized `__new__` " + "whose fields come from the string spec -- the signature is dynamically " + "generated, so inferability is partial (astroid brain reconstructs it)." + ), + ), + Case( + id="callable_kinds.dunder_other", + definition=( + "class Class:\n" + " def __getitem__(self, key):\n" + " return key" + ), + call="Class().__getitem__(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "dunder_other", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "INVOCATION-VS-CALL LIMITATION: `__getitem__` is normally triggered by " + "subscription (`obj[k]`), a `Subscript` node, not a `Call`. Invoking it " + "explicitly as `obj.__getitem__(None)` produces a real Call. A rule " + "wanting to catch subscript-driven dunders must handle Subscript " + "separately -- it will not appear as a Call in idiomatic code." + ), + ), + Case( + id="callable_kinds.abstractmethod", + definition=( + "from abc import ABC, abstractmethod\n" + "class Base(ABC):\n" + " @abstractmethod\n" + " def method(self, parameter):\n" + " ...\n" + "class Concrete(Base):\n" + " def method(self, parameter):\n" + " return parameter" + ), + call="Concrete().method(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "abstractmethod", + "decoration": "decorator_transparent", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@abstractmethod declares a required signature on the ABC; the concrete " + "override is what actually runs. A rule may want to check the call " + "against the abstract declaration or the concrete impl (they match here)." + ), + ), + Case( + id="callable_kinds.functools_partial", + definition=( + "import functools\n" + "def function(first, parameter):\n" + " return (first, parameter)\n" + "partial_function = functools.partial(function, 0)" + ), + call="partial_function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "functools_partial", + "decoration": "undecorated", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "functools.partial pre-binds `first=0`; the remaining call supplies " + "`parameter`. The effective signature is shorter than the underlying " + "function's -- a rule must account for the pre-bound argument. astroid " + "has partial support but reconstruction is partial." + ), + ), + Case( + id="callable_kinds.functools_wraps_decorated", + definition=( + "import functools\n" + "def decorator(wrapped):\n" + " @functools.wraps(wrapped)\n" + " def wrapper(parameter):\n" + " return wrapped(parameter)\n" + " return wrapper\n" + "@decorator\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "functools_wraps_decorated", + "decoration": "decorator_transparent", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@functools.wraps copies metadata (incl. __wrapped__) from the original " + "onto the wrapper, so introspection can recover the true signature. Here " + "the wrapper's signature happens to match, so it is transparent." + ), + ), + Case( + id="callable_kinds.typing_overload", + definition=( + "from typing import overload\n" + "@overload\n" + "def function(parameter: int) -> int: ...\n" + "@overload\n" + "def function(parameter: str) -> str: ...\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "annotated_with_default", + "callable_kind": "typing_overload", + "decoration": "multiple_decorators", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "@typing.overload declares multiple type-only signatures; only the final " + "undecorated implementation actually runs. A rule must pick which " + "signature to check against -- overloads are for the type checker, not " + "runtime dispatch. `annotated_with_default` is the closest annotation " + "tag for the overload-annotated variants (no true default present)." + ), + ), + Case( + id="callable_kinds.signature_rewriting_decorator", + definition=( + "def decorator(wrapped):\n" + " def wrapper(*args, **kwargs):\n" + " return wrapped(*args, **kwargs)\n" + " return wrapper\n" + "@decorator\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "signature_rewriting_decorator", + "decoration": "decorator_opaque", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "uninferable", + }, + notes=( + "Decorator replaces the function with a `(*args, **kwargs)` wrapper and " + "does NOT use functools.wraps, so the true signature is hidden. Any " + "arity/keyword rule sees only *args/**kwargs and cannot validate against " + "`parameter` -- inferability uninferable, arity unknown." + ), + ), + Case( + id="callable_kinds.decoration.multiple", + definition=( + "def first(wrapped):\n" + " return wrapped\n" + "def second(wrapped):\n" + " return wrapped\n" + "@first\n" + "@second\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "decoration": "multiple_decorators", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Two stacked identity decorators, both transparent (return the function " + "unchanged), so the signature survives. Isolates the multiple_decorators " + "decoration axis while keeping the callable a plain module function." + ), + ), + Case( + id="callable_kinds.decoration.parametrized", + definition=( + "def decorator_factory(option):\n" + " def decorator(wrapped):\n" + " return wrapped\n" + " return decorator\n" + "@decorator_factory(option=True)\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "decoration": "parametrized_decorator", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Parametrized decorator (`@decorator_factory(option=True)`): the " + "decorator expression is itself a call. This transparent factory returns " + "the function unchanged, isolating the parametrized_decorator axis." + ), + ), + Case( + id="callable_kinds.decoration.opaque_uninferable", + definition=( + "import external_unresolvable_module\n" + "@external_unresolvable_module.decorate\n" + "def function(parameter):\n" + " return parameter" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "decoration": "decorator_opaque", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "third_party_not_editable", + "inferability": "uninferable", + }, + notes=( + "Decorator from an unresolvable third-party module: astroid cannot infer " + "what it returns, so the post-decoration callable is opaque. A rule " + "cannot know whether the signature was preserved -- uninferable / arity " + "unknown." + ), + ), + Case( + id="callable_kinds.c_builtin", + definition="", + call="len([None])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "decoration": "undecorated", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "C-implemented builtin (`len`) has no introspectable Python signature. " + "astroid knows it exists but not its precise parameter spec, so " + "inferability is partial and source is the no-signature builtin." + ), + ), +] diff --git a/catalog/cross_cutting.py b/catalog/cross_cutting.py new file mode 100644 index 0000000..9652246 --- /dev/null +++ b/catalog/cross_cutting.py @@ -0,0 +1,767 @@ +"""Category: cross-cutting combinations (creative overflow). + +Cases that combine axes or don't cleanly belong to one file: redundant keywords +(`f(x=x)`), parameters shadowing builtins (`def f(list): ...`), reserved-ish +parameter names (`type`, `id`, `input`), passing `self` explicitly +(`Class.method(instance, arg)`), pre-bound `functools.partial` positionals, +overload-dispatch ambiguity, keyword colliding with a `**kwargs` key, and the +argument-name-vs-parameter-name mismatch that motivates the talk's warning rule. +""" + +from catalog._schema import Case + +CATEGORY = "cross_cutting" + +CASES = [ + # --- redundant keyword: f(x=x) -------------------------------------------- + Case( + id="cross_cutting.redundant_keyword.matching_param", + definition="def function(width): ...", + call=( + "width = 10\n" + "function(width=width)" + ), + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The talk's central tension: `function(width=width)` names the param AND " + "the value variable is identical to it. Maximal safety, maximal redundancy. " + "A 'safety without the redundancy' rule might suggest dropping the keyword; " + "an anti-mismatch rule sees a perfect match." + ), + ), + Case( + id="cross_cutting.redundant_keyword.no_matching_param", + definition="def function(**keyword_parameters): ...", + call=( + "width = 10\n" + "function(width=width)" + ), + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Same `f(x=x)` redundancy, but the key is NOT a declared parameter — it is " + "absorbed by **kwargs. No param to match the variable name against, so the " + "keyword is load-bearing (it becomes the dict key) and cannot be dropped." + ), + ), + # --- parameter names shadowing builtins ----------------------------------- + Case( + id="cross_cutting.shadow_builtin.list_param.call_keyword", + definition="def function(list): ...", + call="function(list=[1])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Parameter name `list` shadows the builtin. A 'prefer keyword' rule would " + "make the call site literally spell `list=...`, cementing the shadow at the " + "call site. Interacts with any builtin-shadowing lint (e.g. W0622)." + ), + ), + Case( + id="cross_cutting.shadow_builtin.list_param.call_positional", + definition="def function(list): ...", + call="function([1])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Same builtin-shadowing param `list`, but called positionally. The shadow is " + "invisible at the call site here — converting to keyword would surface it." + ), + ), + # --- reserved-ish / dunder-ish param names -------------------------------- + Case( + id="cross_cutting.reserved_ish_names.type_id_input.call_keyword", + definition="def function(type, id, input): ...", + call="function(type='a', id=1, input='x')", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "All three params shadow builtins (`type`, `id`, `input`). Common in real " + "APIs (requests, dataclasses). Keyword form is idiomatic here despite the " + "shadowing; the names are effectively reserved-by-convention." + ), + ), + Case( + id="cross_cutting.dunder_ish_name.class_.call_keyword", + definition="def function(cls, self): ...", + call="function(cls=object, self=None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "A plain module function whose params are named `cls` and `self` — the " + "conventional method-receiver names, but on a free function. Passing them by " + "keyword is legal yet reads oddly; a receiver-aware rule must not assume " + "these are implicit." + ), + ), + # --- passing self / cls explicitly ---------------------------------------- + Case( + id="cross_cutting.explicit_self.unbound_class_access", + definition=( + "class Shape:\n" + " def method(self, arg):\n" + " ...\n" + "instance = Shape()" + ), + call="Shape.method(instance, 5)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_var_name_match", "pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`self` passed EXPLICITLY via the class object. Signature-wise `self` is a " + "real positional param here; a rule counting args must include the receiver. " + "Contrast with the bound-call form below where `self` is implicit." + ), + ), + Case( + id="cross_cutting.explicit_self.bound_instance_access", + definition=( + "class Shape:\n" + " def method(self, arg):\n" + " ...\n" + "instance = Shape()" + ), + call="instance.method(5)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_self_method", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Same method, bound call: `self` is supplied implicitly by the descriptor. " + "The call passes ONE arg but the signature has TWO params. Any arity/keyword " + "rule must offset the receiver — the mirror image of the explicit-self case." + ), + ), + Case( + id="cross_cutting.explicit_cls.classmethod_via_class", + definition=( + "class Shape:\n" + " @classmethod\n" + " def make(cls, arg):\n" + " ...\n" + ), + call="Shape.make.__func__(Shape, 5)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "classmethod", + "arg_forms": ["pos_var_name_match", "pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Reaching through `.__func__` to call the classmethod's raw function with " + "`cls` passed EXPLICITLY. Pathological but legal; defeats the descriptor's " + "implicit-cls binding. Inference through `__func__` is only partial." + ), + ), + # --- functools.partial: prebound positionals ------------------------------ + Case( + id="cross_cutting.partial.prebound_positional.call_remaining", + definition=( + "import functools\n" + "def function(first, second, third): ...\n" + "bound = functools.partial(function, 1)" + ), + call="bound(2, 3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "functools_partial", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`partial` pre-binds `first`. The partial's effective signature is " + "`(second, third)` even though the underlying def has three params. A rule " + "walking the original signature will miscount unless it models partial." + ), + ), + Case( + id="cross_cutting.partial.prebound_keyword.override_at_call", + definition=( + "import functools\n" + "def function(first, second, third): ...\n" + "bound = functools.partial(function, second=2)" + ), + call="bound(1, third=3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "functools_partial", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`partial` pre-binds `second` as a keyword; the call supplies `first` " + "positionally and `third` by keyword. `second` is already satisfied — a rule " + "must not flag it as missing, and could still override it (partial keywords " + "are overridable)." + ), + ), + Case( + id="cross_cutting.partial.prebound_positional.call_too_many", + definition=( + "import functools\n" + "def function(first, second): ...\n" + "bound = functools.partial(function, 1)" + ), + call="bound(2, 3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "functools_partial", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_many_positional", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + "validity": "runtime_error", + }, + notes=( + "Partial binds `first`; the two-param def now effectively takes one arg, but " + "the call passes two -> TypeError at runtime. The overflow is only visible if " + "the linter accounts for the pre-bound positional." + ), + ), + # --- typing.overload dispatch ambiguity ----------------------------------- + Case( + id="cross_cutting.overload.dispatch_ambiguous", + definition=( + "from typing import overload\n" + "@overload\n" + "def function(value: int) -> int: ...\n" + "@overload\n" + "def function(value: str) -> str: ...\n" + "def function(value): return value" + ), + call="function(x)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": ["annotated_simple", "return_annotation"], + "callable_kind": "typing_overload", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Two `@overload` signatures plus the implementation; the arg `x` is of " + "unknown type, so which overload applies is ambiguous to a linter. The " + "arg-form is a name mismatch against the param `value` under every overload. " + "Runtime dispatches to the single impl regardless." + ), + ), + Case( + id="cross_cutting.overload.differing_param_names", + definition=( + "from typing import overload\n" + "@overload\n" + "def function(width: int) -> int: ...\n" + "@overload\n" + "def function(height: str) -> str: ...\n" + "def function(value): return value" + ), + call="function(value=5)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": ["annotated_simple", "return_annotation"], + "callable_kind": "typing_overload", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "The overloads disagree on the PARAMETER NAME (`width` vs `height`), while " + "the implementation names it `value`. Calling `value=5` matches only the " + "impl signature, not either overload. Which signature a keyword-name rule " + "should trust is genuinely ambiguous." + ), + ), + # --- keyword colliding with what would go into **kwargs ------------------- + Case( + id="cross_cutting.kwargs_collision.explicit_and_kwargs", + definition="def function(name, **kwargs): ...", + call="function(name='a', flag=True)", + tags={ + "definition_kind": ["pos_or_kw", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`name` binds the declared param; `flag` — an arbitrary caller-chosen key — " + "overflows into **kwargs. A name-match rule can check `name` but has NO " + "declared param to match `flag` against; it is legitimately free-form." + ), + ), + Case( + id="cross_cutting.kwargs_collision.same_key_via_double_star", + definition="def function(name, **kwargs): ...", + call=( + "extras = {'name': 'from_dict'}\n" + "function(name='explicit', **extras)" + ), + tags={ + "definition_kind": ["pos_or_kw", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "double_star_map", + "arity": "arity_duplicate", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Explicit `name=` collides with a `name` key inside the `**extras` mapping -> " + "TypeError 'got multiple values for keyword argument' at runtime IF the dict " + "actually contains that key. Statically the dict is a known literal here, so " + "the duplicate is in principle detectable; opaque dicts would hide it." + ), + ), + # --- keyword-only passed positionally (runtime error) --------------------- + Case( + id="cross_cutting.kwonly_passed_positionally.runtime_error", + definition="def function(*, keyword_only): ...", + call="function(5)", + tags={ + "definition_kind": ["bare_star", "kw_only"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_too_many_positional", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "A keyword-only param supplied positionally -> TypeError 'takes 0 positional " + "arguments but 1 was given'. Parses fine; fails at call time. The inverse of " + "the 'prefer keyword' rule's happy path." + ), + ), + # --- positional-only passed by keyword ------------------------------------ + Case( + id="cross_cutting.posonly_passed_by_keyword.runtime_error", + definition="def function(position_only, /): ...", + call="function(position_only=5)", + tags={ + "definition_kind": ["posonly"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_unexpected_keyword", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "Positional-only param addressed by keyword -> TypeError 'got some positional-" + "only arguments passed as keyword arguments'. A naive 'prefer keyword' rule " + "that ignores the `/` would wrongly rewrite pos-only calls into this." + ), + ), + Case( + id="cross_cutting.posonly_by_keyword_absorbed_by_kwargs", + definition="def function(position_only, /, **kwargs): ...", + call="function(1, position_only=2)", + tags={ + "definition_kind": ["posonly", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The subtle exception: with `**kwargs` present, `position_only=2` does NOT " + "error — it lands in kwargs as a string key while the positional `1` binds " + "the pos-only param. So the same keyword form is a runtime error without " + "kwargs but legal with them." + ), + ), + # --- the talk's rectangle progression ------------------------------------- + Case( + id="cross_cutting.rectangle.kwonly.reordered_keywords", + definition="def rectangle(*, width, height): ...", + call=( + "width = 4\n" + "height = 3\n" + "rectangle(height=height, width=width)" + ), + tags={ + "definition_kind": ["bare_star", "kw_only"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_var_match"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_kw_reordered", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The talk's ideal end-state: `*` forces keyword-only, so `rectangle(height=" + "height, width=width)` is unambiguous even though the keywords are reordered " + "relative to the signature. Every value var matches its key. Fully safe." + ), + ), + Case( + id="cross_cutting.rectangle.positional.value_mismatch", + definition="def rectangle(width, height): ...", + call=( + "width = 4\n" + "rectangle(width, width)" + ), + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_match", "pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The bug the talk warns about: `rectangle(width, width)` — the second " + "positional passes `width` into the `height` param. Positionally legal, " + "silently wrong. First arg's var matches its param; second arg's var name " + "(`width`) mismatches the param (`height`)." + ), + ), + Case( + id="cross_cutting.rectangle.positional.mixed_keyword_mismatch", + definition="def rectangle(width, height): ...", + call=( + "width = 4\n" + "rectangle(width, height=width)" + ), + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [ + "pos_var_name_match", "kw", "kw_value_var_mismatch", + ], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Mixed form: `width` positional (var matches param), then `height=width` — " + "the keyword names `height` correctly but the VALUE variable is `width`. The " + "keyword pins the binding (no swap bug), yet the value-name mismatch is " + "exactly what a name-match rule flags." + ), + ), + # --- deeply nested / expression arguments --------------------------------- + Case( + id="cross_cutting.nested_arg.deeply_nested_expr", + definition="def function(value): ...", + call=( + "data = {'items': [[0, 1], [2, 3]]}\n" + "function(data['items'][1][0] + len(str(data)))" + ), + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "A deeply nested subscript+call+arithmetic expression as the sole argument. " + "There is no variable name to compare against the param — a name-match rule " + "has nothing to latch onto, and a keyword-suggestion has no obvious key." + ), + ), + Case( + id="cross_cutting.nested_arg.walrus_in_argument", + definition="def function(value): ...", + call="function((n := 5))", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Walrus assignment inside the argument: `function((n := 5))` both binds `n` " + "and passes 5. The arg is an expression with a side effect; naive rewriting " + "to `value=(n := 5)` preserves it, but the arg is not a plain name." + ), + ), + Case( + id="cross_cutting.nested_arg.bare_genexp_sole_arg", + definition="def function(iterable): ...", + call=( + "y = [1, 2, 3]\n" + "function(x for x in y)" + ), + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Bare generator expression as the SOLE argument — the one context where " + "parentheses may be omitted (`function(x for x in y)`). If a rule rewrites " + "this to `iterable=(x for x in y)` it must re-add the parens; and a genexp " + "cannot be combined with any other argument." + ), + ), + # --- multiple axes at once: imported classmethod + mismatched keywords ---- + Case( + id="cross_cutting.imported_classmethod.mismatched_keyword_vars", + definition=( + "from decimal import Decimal\n" + "w = '4'\n" + "h = '3'" + ), + call="Decimal.from_float(value=w)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "classmethod", + "arg_forms": ["kw", "kw_value_var_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "stdlib_not_editable", + "inferability": "inferable_partial", + }, + notes=( + "Multiple axes: an IMPORTED stdlib CLASSMETHOD (`Decimal.from_float`) called " + "with a KEYWORD whose value variable (`w`) mismatches the param name " + "(`value`). Signature comes from a non-editable C-adjacent source, so " + "inference is only partial — a name-match rule can still see the keyword " + "vs. value-var discrepancy. (Value type is wrong for from_float, but that's " + "a type concern, not an arity one.)" + ), + ), + Case( + id="cross_cutting.dunder_call.instance_as_target", + definition=( + "class Multiplier:\n" + " def __call__(self, factor): ...\n" + "double = Multiplier()" + ), + call="double(factor=2)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "dunder_call", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "The call target is an instance with `__call__`; the effective signature is " + "`__call__` minus `self`. A rule must resolve `double(...)` to `__call__`'s " + "params (`factor`), not treat `double` as an opaque callable." + ), + ), +] diff --git a/catalog/defaults_and_annotations.py b/catalog/defaults_and_annotations.py new file mode 100644 index 0000000..fb66db6 --- /dev/null +++ b/catalog/defaults_and_annotations.py @@ -0,0 +1,581 @@ +"""Category: defaults & annotations (definition axes D3/D4). + +Varies parameter defaults (immutable, mutable, callable, sentinel, expression, +defaults on keyword-only params) and annotations (simple/complex/forward-ref, +annotated-with-default, return annotations). Signature shape is held to a +canonical plain positional-or-keyword param and the target/source/inferability +axes are held canonical (bare name, editable same-module, fully inferable) so +this file isolates the defaults and annotations axes. + +For each default/annotation shape we vary the *call*: omit the defaulted arg +(``arity_defaults_omitted``), pass it positionally, and pass it by keyword -- +the three variants a 'prefer keyword' / 'arg name mismatch' rule cares about. +""" + +from catalog._schema import Case + +CATEGORY = "defaults_and_annotations" + +CASES = [ + # --- no_default: baseline (default axis absent) -------------------------- + Case( + id="defaults_and_annotations.no_default.call_positional", + definition="def function(parameter): ...", + call="function(1)", + tags={ + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Baseline: no default, passed positionally. Convertible to keyword.", + ), + Case( + id="defaults_and_annotations.no_default.call_keyword", + definition="def function(parameter): ...", + call="function(parameter=1)", + tags={ + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="No default, already passed by keyword. The 'prefer keyword' end-state.", + ), + + # --- default_immutable: =None, =1, ="x", =() ----------------------------- + Case( + id="defaults_and_annotations.default_immutable.none.call_omitted", + definition="def function(parameter=None): ...", + call="function()", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Immutable None default, omitted at call. The canonical optional-arg pattern.", + ), + Case( + id="defaults_and_annotations.default_immutable.none.call_positional", + definition="def function(parameter=None): ...", + call="function(1)", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Immutable None default overridden positionally. Convertible to keyword.", + ), + Case( + id="defaults_and_annotations.default_immutable.none.call_keyword", + definition="def function(parameter=None): ...", + call="function(parameter=1)", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Immutable None default overridden by keyword.", + ), + Case( + id="defaults_and_annotations.default_immutable.int.call_positional", + definition="def function(parameter=1): ...", + call="function(2)", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Immutable int literal default, overridden positionally.", + ), + Case( + id="defaults_and_annotations.default_immutable.str.call_omitted", + definition='def function(parameter="x"): ...', + call="function()", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Immutable str literal default, omitted at call.", + ), + Case( + id="defaults_and_annotations.default_immutable.empty_tuple.call_omitted", + definition="def function(parameter=()): ...", + call="function()", + tags={ + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Empty tuple is immutable -- the safe alternative to the =[] anti-pattern.", + ), + + # --- default_mutable: =[], ={}, =set() (classic anti-pattern) ------------ + Case( + id="defaults_and_annotations.default_mutable.list.call_omitted", + definition="def function(parameter=[]): ...", + call="function()", + tags={ + "defaults": "default_mutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Classic mutable-default anti-pattern (=[]); shared across calls. Omitted here.", + ), + Case( + id="defaults_and_annotations.default_mutable.list.call_positional", + definition="def function(parameter=[]): ...", + call="function([1])", + tags={ + "defaults": "default_mutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Mutable list default overridden positionally with a list literal.", + ), + Case( + id="defaults_and_annotations.default_mutable.dict.call_omitted", + definition="def function(parameter={}): ...", + call="function()", + tags={ + "defaults": "default_mutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Mutable dict default anti-pattern (={}), omitted at call.", + ), + Case( + id="defaults_and_annotations.default_mutable.set.call_keyword", + definition="def function(parameter=set()): ...", + call="function(parameter={1})", + tags={ + "defaults": "default_mutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Mutable set() default (a call in the default), overridden by keyword.", + ), + + # --- default_callable: =list, =time.time -------------------------------- + Case( + id="defaults_and_annotations.default_callable.list.call_omitted", + definition="def function(parameter=list): ...", + call="function()", + tags={ + "defaults": "default_callable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Default is the callable `list` itself (a name, not a call). Omitted here.", + ), + Case( + id="defaults_and_annotations.default_callable.time.call_omitted", + definition="import time\ndef function(parameter=time.time): ...", + call="function()", + tags={ + "defaults": "default_callable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Default is an attribute reference to a callable (time.time), not invoked.", + ), + + # --- default_sentinel: =_SENTINEL, =object() ---------------------------- + Case( + id="defaults_and_annotations.default_sentinel.name.call_omitted", + definition="_SENTINEL = object()\ndef function(parameter=_SENTINEL): ...", + call="function()", + tags={ + "defaults": "default_sentinel", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Module-level sentinel name as default; distinguishes 'not passed' from None.", + ), + Case( + id="defaults_and_annotations.default_sentinel.name.call_keyword", + definition="_SENTINEL = object()\ndef function(parameter=_SENTINEL): ...", + call="function(parameter=1)", + tags={ + "defaults": "default_sentinel", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Sentinel-defaulted param overridden by keyword.", + ), + Case( + id="defaults_and_annotations.default_sentinel.object_call.call_omitted", + definition="def function(parameter=object()): ...", + call="function()", + tags={ + "defaults": "default_sentinel", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Inline `object()` sentinel default (a call evaluated once at def time).", + ), + + # --- default_expr: =CONST, =a+b ----------------------------------------- + Case( + id="defaults_and_annotations.default_expr.const.call_omitted", + definition="CONST = 10\ndef function(parameter=CONST): ...", + call="function()", + tags={ + "defaults": "default_expr", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Default is a reference to a module constant name.", + ), + Case( + id="defaults_and_annotations.default_expr.binop.call_positional", + definition="a = 1\nb = 2\ndef function(parameter=a + b): ...", + call="function(5)", + tags={ + "defaults": "default_expr", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Default is a computed expression (a + b), overridden positionally.", + ), + + # --- default_on_kwonly: default on a keyword-only param ------------------ + Case( + id="defaults_and_annotations.default_on_kwonly.call_omitted", + definition="def function(*, parameter=None): ...", + call="function()", + tags={ + "defaults": "default_on_kwonly", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Default on a keyword-only param, omitted. Already forced to keyword form.", + ), + Case( + id="defaults_and_annotations.default_on_kwonly.call_keyword", + definition="def function(*, parameter=None): ...", + call="function(parameter=1)", + tags={ + "defaults": "default_on_kwonly", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Kw-only defaulted param overridden by keyword (the only legal override form).", + ), + + # --- annotations: unannotated is covered above; simple/complex/etc ------- + Case( + id="defaults_and_annotations.annotated_simple.call_positional", + definition="def function(parameter: int): ...", + call="function(1)", + tags={ + "defaults": "no_default", + "annotations": "annotated_simple", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Simple annotation (x: int), no default, passed positionally.", + ), + Case( + id="defaults_and_annotations.annotated_simple.call_keyword", + definition="def function(parameter: int): ...", + call="function(parameter=1)", + tags={ + "defaults": "no_default", + "annotations": "annotated_simple", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Simple annotation (x: int), passed by keyword.", + ), + Case( + id="defaults_and_annotations.annotated_complex.optional.call_omitted", + definition=( + "from typing import List, Optional\n" + "def function(parameter: Optional[List[int]] = None): ..." + ), + call="function()", + tags={ + "defaults": "default_immutable", + "annotations": ["annotated_complex", "annotated_with_default"], + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Complex annotation Optional[List[int]] with immutable None default, omitted.", + ), + Case( + id="defaults_and_annotations.annotated_complex.forward_ref.call_positional", + definition=( + "class Foo: ...\n" + 'def function(parameter: "Foo"): ...' + ), + call="function(Foo())", + tags={ + "defaults": "no_default", + "annotations": "annotated_complex", + "callable_kind": "module_function", + "arg_forms": ["pos_call_result"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Forward-reference string annotation (x: \"Foo\"), passed a constructed Foo.", + ), + Case( + id="defaults_and_annotations.annotated_with_default.call_omitted", + definition="def function(parameter: int = 3): ...", + call="function()", + tags={ + "defaults": "default_immutable", + "annotations": ["annotated_simple", "annotated_with_default"], + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Annotation + immutable default (x: int = 3), omitted at call.", + ), + Case( + id="defaults_and_annotations.annotated_with_default.call_positional", + definition="def function(parameter: int = 3): ...", + call="function(5)", + tags={ + "defaults": "default_immutable", + "annotations": ["annotated_simple", "annotated_with_default"], + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Annotation + immutable default overridden positionally.", + ), + Case( + id="defaults_and_annotations.annotated_with_default.call_keyword", + definition="def function(parameter: int = 3): ...", + call="function(parameter=5)", + tags={ + "defaults": "default_immutable", + "annotations": ["annotated_simple", "annotated_with_default"], + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Annotation + immutable default overridden by keyword.", + ), + Case( + id="defaults_and_annotations.return_annotation.call_positional", + definition="def function(parameter: int) -> None: ...", + call="function(1)", + tags={ + "defaults": "no_default", + "annotations": ["annotated_simple", "return_annotation"], + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Return annotation (-> None) alongside a simple param annotation.", + ), + Case( + id="defaults_and_annotations.return_annotation.no_params.call_empty", + definition="def function() -> int: ...", + call="function()", + tags={ + "defaults": "no_default", + "annotations": "return_annotation", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Pure return annotation (-> int) with no parameters; isolates the return-anno axis.", + ), +] diff --git a/catalog/param_kinds.py b/catalog/param_kinds.py new file mode 100644 index 0000000..3de14e6 --- /dev/null +++ b/catalog/param_kinds.py @@ -0,0 +1,604 @@ +"""Category: parameter kinds & counts (definition axes D1/D2). + +Varies the *signature shape*: positional-only (`/`), positional-or-keyword, +var-positional (`*args`), the bare `*` separator, keyword-only, var-keyword +(`**kwargs`), the no-parameter case, and parameter counts. The call is held to a +canonical minimal form so this file isolates the definition shape. + +This module doubles as the reference exemplar for the other category files. +""" + +from catalog._schema import Case + +CATEGORY = "param_kinds" + +CASES = [ + Case( + id="param_kinds.no_params.call_empty", + definition="def function(): ...", + call="function()", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Baseline: no parameters, no arguments. Nothing any arg-rule can act on.", + ), + Case( + id="param_kinds.pos_or_kw.single.call_positional_literal", + definition="def function(parameter): ...", + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Plain positional-or-keyword param, passed positionally. Convertible to keyword.", + ), + Case( + id="param_kinds.posonly.single.call_positional_literal", + definition="def function(position_only_parameter, /): ...", + call="function(None)", + tags={ + "definition_kind": ["posonly"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Positional-only param invoked positionally. A 'prefer keyword' rule " + "likely should NOT fire — keyword form is illegal for pos-only params." + ), + ), + Case( + id="param_kinds.bare_star.kw_only.call_keyword", + definition="def function(*, keyword_only_parameter): ...", + call="function(keyword_only_parameter=None)", + tags={ + "definition_kind": ["bare_star", "kw_only"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Bare `*` forces keyword-only; call already uses the keyword. The 'add *' rule's target end-state.", + ), + Case( + id="param_kinds.var_positional.call_two_positional", + definition="def function(*arbitrary_parameters): ...", + call="function(None, None)", + tags={ + "definition_kind": ["var_positional"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`*args` absorbs positionals; these have no parameter names, so no keyword form exists.", + ), + Case( + id="param_kinds.var_keyword.call_keyword", + definition="def function(**keyword_parameters): ...", + call="function(argument=None)", + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`**kwargs` absorbs arbitrary keywords; the key is caller-chosen, not a declared param.", + ), + Case( + id="param_kinds.all_kinds.call_mixed", + definition="def function(po, /, pk, *args, ko, **kw): ...", + call="function(1, 2, 3, ko=4, extra=5)", + tags={ + "definition_kind": [ + "posonly", "pos_or_kw", "var_positional", "kw_only", "var_keyword", + ], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Every parameter kind at once — the stress case for any signature-walking rule.", + ), + # --- pos-or-kw: few and many ------------------------------------------- + Case( + id="param_kinds.pos_or_kw.few.call_positional_literal", + definition="def function(first, second, third): ...", + call="function(None, None, None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Three plain pos-or-kw params, all passed positionally. Each is convertible to keyword.", + ), + Case( + id="param_kinds.pos_or_kw.many.call_positional_literal", + definition="def function(a, b, c, d, e, f, g, h): ...", + call="function(1, 2, 3, 4, 5, 6, 7, 8)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Eight pos-or-kw params passed positionally — the classic 'too many positionals, prefer keywords' target.", + ), + Case( + id="param_kinds.pos_or_kw.many.call_all_keyword", + definition="def function(a, b, c, d, e, f, g, h): ...", + call="function(a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Same 8-param signature, but every argument passed by keyword — the 'prefer keyword' end-state.", + ), + # --- pos-or-kw single, called by keyword ------------------------------- + Case( + id="param_kinds.pos_or_kw.single.call_keyword", + definition="def function(parameter): ...", + call="function(parameter=None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Plain pos-or-kw param passed using the keyword form. Same signature as the positional exemplar; distinct call.", + ), + # --- positional-only: multiple, and mix at the / boundary -------------- + Case( + id="param_kinds.posonly.multiple.call_positional_literal", + definition="def function(first, second, third, /): ...", + call="function(1, 2, 3)", + tags={ + "definition_kind": ["posonly"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Three positional-only params before the `/`. None can be passed by keyword.", + ), + Case( + id="param_kinds.posonly.mix_boundary.call_positional_literal", + definition="def function(position_only, /, pos_or_kw): ...", + call="function(1, 2)", + tags={ + "definition_kind": ["posonly", "pos_or_kw"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="The `/` boundary: first param is pos-only, second is pos-or-kw. Second could be keyword, first cannot.", + ), + Case( + id="param_kinds.posonly.mix_boundary.call_second_keyword", + definition="def function(position_only, /, pos_or_kw): ...", + call="function(1, pos_or_kw=2)", + tags={ + "definition_kind": ["posonly", "pos_or_kw"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Same `/` boundary signature; the pos-or-kw param is passed by keyword while the pos-only stays positional.", + ), + # --- keyword-only via bare * with multiple params ---------------------- + Case( + id="param_kinds.bare_star.multiple_kw_only.call_keyword", + definition="def function(*, first, second, third): ...", + call="function(first=1, second=2, third=3)", + tags={ + "definition_kind": ["bare_star", "kw_only"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Bare `*` makes three params keyword-only; all passed by keyword.", + ), + Case( + id="param_kinds.bare_star.mix_boundary.call_mixed", + definition="def function(leading, *, keyword_only): ...", + call="function(1, keyword_only=2)", + tags={ + "definition_kind": ["pos_or_kw", "bare_star", "kw_only"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="The `*` boundary: one pos-or-kw param before the bare star, one keyword-only after it.", + ), + # --- keyword-only AFTER *args (not bare star) -------------------------- + Case( + id="param_kinds.var_positional_then_kw_only.call_mixed", + definition="def function(*args, keyword_only): ...", + call="function(1, 2, keyword_only=3)", + tags={ + "definition_kind": ["var_positional", "kw_only"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`*args` (not a bare star) also introduces keyword-only params after it. Positionals feed args; the kw-only must be keyword.", + ), + Case( + id="param_kinds.var_positional_then_multiple_kw_only.call_mixed", + definition="def function(*args, first, second): ...", + call="function(1, first=2, second=3)", + tags={ + "definition_kind": ["var_positional", "kw_only"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`*args` followed by two keyword-only params. Distinguishes the after-*args case from the bare-star case.", + ), + # --- *args alone, called with zero args -------------------------------- + Case( + id="param_kinds.var_positional.call_empty", + definition="def function(*arbitrary_parameters): ...", + call="function()", + tags={ + "definition_kind": ["var_positional"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`*args` accepts zero positionals — the empty-call boundary for var-positional.", + ), + # --- **kwargs alone, called with zero args ----------------------------- + Case( + id="param_kinds.var_keyword.call_empty", + definition="def function(**keyword_parameters): ...", + call="function()", + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`**kwargs` accepts zero keywords — the empty-call boundary for var-keyword.", + ), + # --- *args, **kwargs together ------------------------------------------ + Case( + id="param_kinds.var_positional_and_var_keyword.call_mixed", + definition="def function(*args, **kwargs): ...", + call="function(1, 2, key=3)", + tags={ + "definition_kind": ["var_positional", "var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`*args, **kwargs` — the fully-variadic signature. Positionals absorbed by args, " + "keywords by kwargs. arity tag picks the positional side; the keyword absorption is also present." + ), + ), + # --- pos-or-kw leading, then *args, **kwargs --------------------------- + Case( + id="param_kinds.leading_pos_or_kw_with_varargs_kwargs.call_mixed", + definition="def function(required, *args, **kwargs): ...", + call="function(1, 2, 3, key=4)", + tags={ + "definition_kind": ["pos_or_kw", "var_positional", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="One required pos-or-kw param, then *args and **kwargs. First positional binds `required`; the rest overflow.", + ), + # --- posonly + varargs boundary ---------------------------------------- + Case( + id="param_kinds.posonly_then_varargs.call_positional", + definition="def function(position_only, /, *args): ...", + call="function(1, 2, 3)", + tags={ + "definition_kind": ["posonly", "var_positional"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Pos-only param at the `/` boundary followed directly by `*args`. First positional binds pos-only; rest to args.", + ), + # --- pos-or-kw + kwargs (no varargs) ----------------------------------- + Case( + id="param_kinds.pos_or_kw_and_var_keyword.call_mixed", + definition="def function(first, second, **kwargs): ...", + call="function(1, 2, extra=3)", + tags={ + "definition_kind": ["pos_or_kw", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Two pos-or-kw params plus **kwargs (no *args). Named positionals bind; the extra keyword overflows into kwargs.", + ), + # --- kw-only + var_keyword --------------------------------------------- + Case( + id="param_kinds.kw_only_and_var_keyword.call_keyword", + definition="def function(*, keyword_only, **kwargs): ...", + call="function(keyword_only=1, extra=2)", + tags={ + "definition_kind": ["bare_star", "kw_only", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Bare `*` gives a keyword-only param, then **kwargs. The declared kw-only binds; the extra keyword overflows.", + ), + # --- posonly + kw-only, skipping the pos-or-kw middle ------------------ + Case( + id="param_kinds.posonly_and_kw_only.call_mixed", + definition="def function(position_only, /, *, keyword_only): ...", + call="function(1, keyword_only=2)", + tags={ + "definition_kind": ["posonly", "bare_star", "kw_only"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="`/` and bare `*` with no pos-or-kw params between them: one pos-only, one kw-only. Each kind has exactly one legal call form.", + ), + # --- full grid, called entirely positionally where legal --------------- + Case( + id="param_kinds.all_kinds.call_minimal_positional", + definition="def function(po, /, pk, *args, ko, **kw): ...", + call="function(1, 2, ko=3)", + tags={ + "definition_kind": [ + "posonly", "pos_or_kw", "var_positional", "kw_only", "var_keyword", + ], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Same full-grid signature as the exemplar but the minimal legal call: no *args items, " + "no **kw items — only the mandatory pos-only, pos-or-kw and kw-only params." + ), + ), + # --- pos-or-kw passed positionally then next by keyword ---------------- + Case( + id="param_kinds.pos_or_kw.few.call_positional_then_keyword", + definition="def function(first, second, third): ...", + call="function(1, second=2, third=3)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes="Same three-param signature called with a mix: first positional, remaining by keyword. A common 'partially converted' call.", + ), +] diff --git a/catalog/source_and_inferability.py b/catalog/source_and_inferability.py new file mode 100644 index 0000000..43fc673 --- /dev/null +++ b/catalog/source_and_inferability.py @@ -0,0 +1,653 @@ +"""Category: definition source & inferability (environment axes E1/E2/E3). + +Varies where the callable comes from and whether astroid can infer its +definition: same-module (editable), same-project, standard library (not +editable), third-party (not editable), C builtins with no introspectable +signature (`len`, `print`), dynamically generated / uninferable, stub-only. +Also inferability (full/partial/uninferable) and multiplicity of inference. + +This axis governs whether an auto-fix rule is even *allowed* to act (you cannot +edit stdlib), and mirrors the `next(func.infer())` boundary in +`my_plugin.check_call_arguments`. + +Non-owned axes are held canonical: the call is a plain positional literal call +(`arg_forms: pos_literal`, `arity_exact`, `order_canonical`) unless the case is +specifically about a call target that changes inference. The signature, where we +own it, is a plain `def function(parameter): ...`. +""" + +from catalog._schema import Case + +CATEGORY = "source_and_inferability" + +CASES = [ + # --- same_module_editable --------------------------------------------- + Case( + id="source_and_inferability.same_module.def_and_call", + definition="def function(parameter): ...", + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Def and call in one snippet — the fully editable, fully inferable " + "baseline. `next(func.infer())` yields the FunctionDef directly. An " + "autofix rule may freely edit both the definition and the call site." + ), + ), + Case( + id="source_and_inferability.same_module.alias_binding", + definition=( + "def function(parameter): ...\n" + "alias = function" + ), + call="alias(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_alias", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Same-module def rebound to another name, called through the alias. " + "astroid follows the assignment so inference still resolves to the " + "original FunctionDef; the callable remains editable." + ), + ), + # --- multiplicity: name bound to >1 definition ------------------------ + Case( + id="source_and_inferability.same_module.conditional_def_ambiguous", + definition=( + "import sys\n" + "if sys.argv:\n" + " def function(parameter): ...\n" + "else:\n" + " def function(other_parameter, extra): ...\n" + ), + call="function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "MULTIPLICITY: the name `function` is bound to two distinct FunctionDefs " + "in if/else branches with DIFFERENT signatures. `func.infer()` yields " + "more than one candidate; `next(...)` takes only the first, so the plugin " + "sees an arbitrary one of the two. A rule must handle ambiguous inference " + "before autofixing. Tagged inferable_partial; arity_unknown reflects that " + "the effective signature depends on which branch inference picks." + ), + ), + Case( + id="source_and_inferability.same_module.reassigned_after_def", + definition=( + "def function(parameter): ...\n" + "function = lambda x, y: None\n" + ), + call="function(None, None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "lambda", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "MULTIPLICITY: the name is a def then rebound to a lambda with a different " + "arity. Inference may yield both the FunctionDef and the Lambda; `next()` " + "picks the first in flow order. Documents that a single name can carry " + "several callable definitions in the same editable module." + ), + ), + # --- same_project_editable -------------------------------------------- + Case( + id="source_and_inferability.same_project.imported_helper", + definition="from mypkg.util import helper", + call="helper(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "same_project_editable", + "inferability": "uninferable", + }, + notes=( + "Callable imported from a sibling module WE OWN (editable). Whether astroid " + "resolves it depends on `mypkg` being on the path at lint time; in this " + "isolated snippet `mypkg` does not exist so inference fails (Uninferable / " + "raises). The editability boundary (we could add `*` to the def) is what " + "matters here, independent of whether inference happens to succeed." + ), + ), + Case( + id="source_and_inferability.same_project.imported_module_attr", + definition="from mypkg import util", + call="util.helper(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "same_project_editable", + "inferability": "uninferable", + }, + notes=( + "Same-project callable reached via a module attribute (`util.helper`) rather " + "than a direct import. Editable in principle; inference requires resolving " + "`mypkg.util` from disk. Contrasts target_attribute against the direct " + "target_imported case above." + ), + ), + # --- stdlib_not_editable ---------------------------------------------- + Case( + id="source_and_inferability.stdlib.json_dumps", + definition="import json", + call="json.dumps(obj, indent=2)", + tags={ + "definition_kind": ["pos_or_kw", "kw_only"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch", "kw", "kw_value_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_positional_then_keyword", + "call_target": "target_attribute", + "source": "stdlib_not_editable", + "inferability": "inferable_full", + }, + notes=( + "Stdlib pure-Python function; astroid can typically infer `json.dumps` to a " + "real FunctionDef with a full signature. But the source is NOT editable — an " + "autofix rule must not attempt to add `*` to `json.dumps`; it may only rewrite " + "the call site. `obj` is an undefined name (name-mismatch vs the `obj` param)." + ), + ), + Case( + id="source_and_inferability.stdlib.os_path_join", + definition="import os", + call="os.path.join(a, b)", + tags={ + "definition_kind": ["pos_or_kw", "var_positional"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "stdlib_not_editable", + "inferability": "inferable_partial", + }, + notes=( + "Stdlib `os.path.join(a, b, *p)` reached through a nested module attribute. " + "`os.path` is dynamically assigned (posixpath/ntpath) so inference is " + "platform/brain-dependent — tagged inferable_partial. Non-editable; the " + "trailing `*p` means later positionals have no keyword form." + ), + ), + Case( + id="source_and_inferability.stdlib.datetime_constructor", + definition="import datetime", + call="datetime.datetime(2020, 1, 1)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "stdlib_not_editable", + "inferability": "inferable_partial", + }, + notes=( + "Instantiating a stdlib C-implemented class (`datetime.datetime`). Inference " + "yields the ClassDef via astroid's brain, but the __init__/__new__ signature " + "is C-level, so parameter binding is only partially recoverable. Not editable." + ), + ), + Case( + id="source_and_inferability.stdlib.from_import_function", + definition="from functools import reduce", + call="reduce(function, sequence)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "stdlib_not_editable", + "inferability": "inferable_full", + }, + notes=( + "Stdlib function brought into scope via `from ... import` and called by bare " + "name (target_imported, not target_attribute). `functools.reduce` is " + "pure-Python so astroid infers a full signature; still non-editable." + ), + ), + # --- third_party_not_editable ----------------------------------------- + Case( + id="source_and_inferability.third_party.requests_get", + definition="import requests", + call="requests.get(url)", + tags={ + "definition_kind": ["pos_or_kw", "var_keyword"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "third_party_not_editable", + "inferability": "uninferable", + }, + notes=( + "Third-party package. The snippet PARSES even though `requests` isn't " + "installed in this environment — that is the point: inference cannot resolve " + "an uninstalled/unavailable dependency, so `next(func.infer())` fails or " + "returns Uninferable. Not editable regardless. `requests.get(url, **kwargs)`." + ), + ), + Case( + id="source_and_inferability.third_party.numpy_array", + definition="import numpy", + call="numpy.array([1, 2])", + tags={ + "definition_kind": ["pos_or_kw", "kw_only"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "third_party_not_editable", + "inferability": "uninferable", + }, + notes=( + "Third-party C-extension callable (`numpy.array`). Even when numpy IS " + "installed its array constructor is a C function with no introspectable " + "Python signature; here numpy is absent so inference simply fails. " + "Non-editable; documents the C-extension flavour of third-party." + ), + ), + Case( + id="source_and_inferability.third_party.from_import_alias", + definition="from pandas import DataFrame as DF", + call="DF(data)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_alias", + "source": "third_party_not_editable", + "inferability": "uninferable", + }, + notes=( + "Third-party class imported under an alias and instantiated. Combines the " + "alias target form with an uninferable third-party source (pandas not " + "installed). Documents `from x import Y as Z` binding for the source axis." + ), + ), + # --- c_builtin_no_signature ------------------------------------------- + Case( + id="source_and_inferability.c_builtin.len", + definition="", + call="len([1, 2])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "arg_forms": ["pos_literal_container"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "C builtin `len`. `definition` is empty (no import needed). astroid HAS a " + "brain stub so `func.infer()` yields a node, but the underlying signature is " + "C-level with no keyword-able params — you cannot write `len(obj=[...])`. " + "inferable_partial: the object infers, the true parameter names do not. " + "Non-editable; an 'add *' autofix must never target a builtin." + ), + ), + Case( + id="source_and_inferability.c_builtin.print", + definition="", + call='print("x")', + tags={ + "definition_kind": ["var_positional", "kw_only"], + "param_count": "boundary", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "`print(*values, sep=' ', end='\\n', ...)` — a C builtin whose real signature " + "is variadic with keyword-only options. astroid brain provides a node but the " + "positional `*values` have no individual parameter names. Not editable." + ), + ), + Case( + id="source_and_inferability.c_builtin.min_two_args", + definition="", + call="min(a, b)", + tags={ + "definition_kind": ["var_positional", "kw_only"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "`min` is overloaded at the C level (min(iterable) vs min(a, b, *rest, key=...)). " + "No introspectable Python signature; the two positionals cannot be named. " + "Documents an overloaded builtin under the c_builtin source." + ), + ), + Case( + id="source_and_inferability.c_builtin.range", + definition="", + call="range(10)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "boundary", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "`range` is a C builtin TYPE; calling it is a constructor. Its positional-only " + "args (`range(stop)` / `range(start, stop, step)`) cannot be passed by keyword, " + "so no 'prefer keyword' fix applies. Not editable." + ), + ), + Case( + id="source_and_inferability.c_builtin.sorted", + definition="", + call="sorted(seq)", + tags={ + "definition_kind": ["pos_or_kw", "kw_only"], + "param_count": "boundary", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "c_builtin", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "c_builtin_no_signature", + "inferability": "inferable_partial", + }, + notes=( + "`sorted(iterable, /, *, key=None, reverse=False)` — a C builtin with a " + "positional-only first arg and keyword-only options. astroid brain infers a " + "node; the iterable cannot be named. Non-editable." + ), + ), + # --- dynamically_generated -------------------------------------------- + Case( + id="source_and_inferability.dynamic.factory_result", + definition=( + "def make_fn():\n" + " def inner(x): ...\n" + " return inner\n" + "f = make_fn()" + ), + call="f(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "nested_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_call_result", + "source": "dynamically_generated", + "inferability": "inferable_partial", + }, + notes=( + "Callable built at runtime by a factory (`f = make_fn()`). astroid CAN often " + "follow the return value to the nested `inner` FunctionDef, so this is a case " + "where a dynamically-produced callable is still partially inferable. The call " + "target is the result of a call, not a name." + ), + ), + Case( + id="source_and_inferability.dynamic.getattr_result", + definition="obj = object()", + call='getattr(obj, "method")(None)', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "instance_method", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_uninferable", + "source": "dynamically_generated", + "inferability": "uninferable", + }, + notes=( + "Callable fetched via `getattr(obj, \"method\")` then invoked. The attribute " + "name is a runtime string, so astroid cannot resolve which callable this is — " + "`next(func.infer())` yields Uninferable. Canonical uninferable/dynamic case; " + "no rule can safely act on the signature." + ), + ), + Case( + id="source_and_inferability.dynamic.type_created_class", + definition='Dynamic = type("Dynamic", (), {})', + call="Dynamic()", + tags={ + "definition_kind": ["no_params"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "class_constructor", + "arg_forms": [], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_class_instantiation", + "source": "dynamically_generated", + "inferability": "uninferable", + }, + notes=( + "Class synthesised at runtime with the 3-arg `type(...)`. There is no ClassDef " + "node to inspect, so its __init__ signature is uninferable. Documents " + "dynamically-generated *classes* (constructors), not just functions." + ), + ), + Case( + id="source_and_inferability.dynamic.decorator_replaces_callable", + definition=( + "def decorator(f):\n" + " def wrapper(*args, **kwargs): ...\n" + " return wrapper\n" + "@decorator\n" + "def function(parameter): ...\n" + ), + call="function(None)", + tags={ + "definition_kind": ["var_positional", "var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "signature_rewriting_decorator", + "decoration": "decorator_opaque", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_absorbed_by_varargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "dynamically_generated", + "inferability": "inferable_partial", + }, + notes=( + "An opaque decorator that returns a NEW `(*args, **kwargs)` wrapper. The name " + "`function` is defined in-module (editable) but its EFFECTIVE runtime signature " + "is the wrapper's — dynamically generated. Inference of the decorated name may " + "yield the wrapper rather than the original def, so a signature-based fix would " + "act on the wrong signature. inferable_partial with the ambiguity noted." + ), + ), + # --- stub_only --------------------------------------------------------- + Case( + id="source_and_inferability.stub_only.pyi_signature", + definition="from _stubbed import stubbed_function", + call="stubbed_function(None)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "one", + "defaults": "no_default", + "annotations": "annotated_simple", + "callable_kind": "module_function", + "arg_forms": ["pos_literal"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_imported", + "source": "stub_only", + "inferability": "inferable_partial", + }, + notes=( + "Callable whose signature exists ONLY in a `.pyi` stub (no editable runtime " + "body). astroid can read the stub's parameter list, so the signature is " + "known (inferable_partial — signature only, no body), but there is nothing to " + "autofix: you cannot add `*` to a stub you don't own, and the .py has no def. " + "This snippet cannot resolve `_stubbed` in isolation; the tag documents the " + "stub-only editability boundary." + ), + ), + Case( + id="source_and_inferability.stub_only.c_ext_with_pyi", + definition="import _speedups", + call="_speedups.fast_call(a, b)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "annotated_simple", + "callable_kind": "c_builtin", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": "none", + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_attribute", + "source": "stub_only", + "inferability": "inferable_partial", + }, + notes=( + "A C-extension module whose ONLY Python-visible signature is a shipped `.pyi` " + "stub. The real implementation is compiled and non-editable; the stub gives " + "parameter names but is also not something a lint autofix should rewrite. " + "Contrasts stub-over-C-extension with the pure stub_only case above." + ), + ), +] diff --git a/catalog/unpacking.py b/catalog/unpacking.py new file mode 100644 index 0000000..ad4cae5 --- /dev/null +++ b/catalog/unpacking.py @@ -0,0 +1,524 @@ +"""Category: unpacking at the call site (call axis C2). + +Varies iterable/mapping unpacking in the call: `f(*seq)`, `f(**mapping)`, mixed +`f(1, *rest, k=2, **more)`, and known-length/literal unpacking vs opaque +(uninferable) unpacking. Definition held to small canonical signatures. + +The load-bearing distinction here is *inferability of the unpacked value*: + + - literal / known-length unpacking (`unpack_literal_known`) — a tuple/list + literal or a fixed-arity helper whose element count astroid can resolve. + An arg-name / prefer-keyword / positional->keyword rule can, in principle, + map each unpacked element onto a concrete parameter and act. + + - opaque unpacking (`unpack_opaque`) — `*name`, `*get_seq()`, `**kwargs`, + `**get_kwargs()` where the length/keys are NOT statically known + (`inferability: inferable_partial`). Any autofix that renames args, converts + positional->keyword, or checks arity MUST bail: it cannot know how many + parameters the star consumes nor which keywords the double-star supplies, so + rewriting risks changing call semantics. Arity is likewise `arity_unknown`. +""" + +from catalog._schema import Case + +CATEGORY = "unpacking" + +CASES = [ + # --- star_seq: iterable unpacking ------------------------------------ + Case( + id="unpacking.star_seq.tuple_var_known_len", + definition="def function(a, b): ...", + call="pair = (1, 2)\nfunction(*pair)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": ["star_seq", "unpack_literal_known"], + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "`*pair` where pair is a 2-tuple literal bound just above. astroid can " + "infer length 2 -> maps onto (a, b). A positional->keyword rule could " + "in principle expand this to function(a=1, b=2), but only because the " + "length is statically known." + ), + ), + Case( + id="unpacking.star_seq.list_literal_known_len", + definition="def function(a, b): ...", + call="function(*[1, 2])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": ["star_seq", "unpack_literal_known"], + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Inline list literal of known length 2. Same inferable situation as the " + "tuple case; length is visible at the call site." + ), + ), + Case( + id="unpacking.star_seq.range_known_len", + definition="def function(a, b, c): ...", + call="function(*range(3))", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_call_result"], + "unpacking": ["star_seq", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`*range(3)` has a length obvious to a human (3) but astroid does not " + "constant-fold range() into a known-length sequence, so element count is " + "not statically resolved. Treated as opaque: arity_unknown; an autofix " + "cannot safely map elements onto a/b/c." + ), + ), + Case( + id="unpacking.star_seq.opaque_call_result", + definition="def function(a, b): ...", + call="def get_seq():\n return (1, 2)\nfunction(*get_seq())", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_call_result"], + "unpacking": ["star_seq", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`*get_seq()` — even though the helper returns a 2-tuple, a rule should " + "treat the unpacked length as unknown (return values can vary, inference " + "may be incomplete). Autofix must bail: renaming/keyword conversion " + "cannot enumerate the elements safely." + ), + ), + Case( + id="unpacking.star_seq.opaque_name", + definition="def function(a, b): ...", + call="function(*rest)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": ["star_seq", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`*rest` where rest is an undefined/free name — no binding to infer a " + "length from. Fully opaque; arity_unknown. Canonical bail case for any " + "arg-name or positional->keyword rule." + ), + ), + # --- double_star_map: mapping unpacking ------------------------------ + Case( + id="unpacking.double_star_map.dict_literal_known_keys", + definition="def function(a, b): ...", + call='function(**{"a": 1, "b": 2})', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw_value_literal"], + "unpacking": ["double_star_map", "unpack_literal_known"], + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Dict literal with statically-visible string keys 'a','b' matching the " + "parameter names. A rule could in principle rewrite to function(a=1, b=2). " + "Known keys are what make this actionable." + ), + ), + Case( + id="unpacking.double_star_map.dict_var_known_keys", + definition="def function(a, b): ...", + call='mapping = {"a": 1, "b": 2}\nfunction(**mapping)', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw_value_literal"], + "unpacking": ["double_star_map", "unpack_literal_known"], + "arity": "arity_exact", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Dict literal bound to a name just above; keys still statically known via " + "inference. Borderline: a conservative rule may already treat a named " + "dict as opaque even though astroid can resolve it here." + ), + ), + Case( + id="unpacking.double_star_map.opaque_kwargs_name", + definition="def function(a, b): ...", + call="function(**kwargs)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw"], + "unpacking": ["double_star_map", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`**kwargs` — free name, keys unknown. The keyword set supplied is " + "invisible, so arity and name-matching are undecidable. Autofix must bail: " + "it cannot know whether 'a'/'b' are covered or which extra keys exist." + ), + ), + Case( + id="unpacking.double_star_map.opaque_call_result", + definition="def function(a, b): ...", + call='def get_kwargs():\n return {"a": 1, "b": 2}\nfunction(**get_kwargs())', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw_value_literal"], + "unpacking": ["double_star_map", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "`**get_kwargs()` — keys produced dynamically by a call; not statically " + "known. Opaque even though the helper body reveals them, since return " + "values may vary. Autofix bails." + ), + ), + # --- mixed_unpacking ------------------------------------------------- + Case( + id="unpacking.mixed_unpacking.full_signature", + definition="def function(a, /, b, *args, c, **kw): ...", + call="rest = (2, 9)\nextra = {'d': 4}\nfunction(1, *rest, c=3, **extra)", + tags={ + "definition_kind": [ + "posonly", "pos_or_kw", "var_positional", "kw_only", "var_keyword", + ], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "pos_expr", "kw", "kw_value_literal"], + "unpacking": ["mixed_unpacking", "star_seq", "double_star_map", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Positional literal + `*rest` + keyword + `**extra` against a signature " + "spanning every parameter kind. `*rest` feeds b then *args; `**extra` " + "feeds **kw. Even with literal helpers the interaction of star + varargs " + "makes the per-parameter mapping ambiguous; treat as opaque/arity_unknown. " + "Any rewrite must bail." + ), + ), + Case( + id="unpacking.mixed_unpacking.opaque_both_stars", + definition="def function(a, /, b, *args, c, **kw): ...", + call="function(1, *rest, c=3, **extra)", + tags={ + "definition_kind": [ + "posonly", "pos_or_kw", "var_positional", "kw_only", "var_keyword", + ], + "param_count": "many", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "pos_var_name_mismatch", "kw", "kw_value_literal"], + "unpacking": ["mixed_unpacking", "star_seq", "double_star_map", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Same signature but rest/extra are free names — doubly opaque. Neither the " + "positional fill of b/*args nor the keyword fill of **kw is knowable. " + "The canonical 'do nothing' case for arity + keyword rules." + ), + ), + # --- star_seq into signatures with defaults -------------------------- + Case( + id="unpacking.star_seq.into_defaults_known_len", + definition="def function(a, b, c=0): ...", + call="pair = (1, 2)\nfunction(*pair)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_expr"], + "unpacking": ["star_seq", "unpack_literal_known"], + "arity": "arity_defaults_omitted", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Known-length `*pair` (len 2) into a signature where c has a default. " + "Fills a,b; c falls back to its default -> arity_defaults_omitted. Only " + "decidable because the length is inferable." + ), + ), + Case( + id="unpacking.star_seq.into_defaults_opaque", + definition="def function(a, b, c=0): ...", + call="function(*args)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "default_immutable", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_var_name_mismatch"], + "unpacking": ["star_seq", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Opaque `*args` into a defaulted signature. Cannot tell whether c is " + "supplied or defaulted, nor whether arity is satisfied -> arity_unknown." + ), + ), + # --- double_star into **kwargs sink ---------------------------------- + Case( + id="unpacking.double_star_map.into_var_keyword_known", + definition="def function(**keyword_parameters): ...", + call='function(**{"x": 1, "y": 2})', + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw_value_literal"], + "unpacking": ["double_star_map", "unpack_literal_known"], + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + }, + notes=( + "Known-key dict unpacked into a `**kwargs` sink. Keys are absorbed with no " + "declared parameter to name-match against; a prefer-keyword rule has no " + "concrete target params here even though keys are visible." + ), + ), + Case( + id="unpacking.double_star_map.into_var_keyword_opaque", + definition="def function(**keyword_parameters): ...", + call="function(**kwargs)", + tags={ + "definition_kind": ["var_keyword"], + "param_count": "zero", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["kw"], + "unpacking": ["double_star_map", "unpack_opaque"], + "arity": "arity_absorbed_by_kwargs", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Opaque `**kwargs` forwarded straight into a `**kwargs` sink — the classic " + "wrapper/passthrough. Keys unknown but always legal (absorbed). No rule " + "can or should act." + ), + ), + # --- over/under-filling arity via unpacking -------------------------- + Case( + id="unpacking.star_seq.overfill_known_len", + definition="def function(a, b): ...", + call="function(*[1, 2, 3])", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": ["star_seq", "unpack_literal_known"], + "arity": "arity_too_many_positional", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "Known-length 3 unpacked into a 2-param signature — provably too many " + "positionals; raises TypeError at runtime. Because length is inferable an " + "arity rule CAN flag it. Contrast the opaque cases where it cannot." + ), + ), + Case( + id="unpacking.star_seq.underfill_known_len", + definition="def function(a, b, c): ...", + call="function(*(1,))", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal_container"], + "unpacking": ["star_seq", "unpack_literal_known"], + "arity": "arity_too_few", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "Known-length 1 unpacked into a 3-param signature — provably too few; " + "missing b, c at runtime. Inferable length makes this decidable for an " + "arity rule." + ), + ), + Case( + id="unpacking.star_seq.overfill_opaque_undecidable", + definition="def function(a, b): ...", + call="function(*get_seq())", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_call_result"], + "unpacking": ["star_seq", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_canonical", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Might over- or under-fill (a,b) but the length is opaque -> arity_unknown. " + "An arity rule must NOT emit too-few/too-many here; the mirror of the two " + "known-length runtime_error cases above." + ), + ), + Case( + id="unpacking.double_star_map.duplicate_known_key", + definition="def function(a, b): ...", + call='function(1, **{"a": 2})', + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw_value_literal"], + "unpacking": ["double_star_map", "unpack_literal_known"], + "arity": "arity_duplicate", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_full", + "validity": "runtime_error", + }, + notes=( + "Positional `1` fills a, then `**{'a': 2}` supplies key 'a' again -> " + "'multiple values for argument a' TypeError. Detectable only because the " + "unpacked key is a known literal. With opaque **kwargs this collision is " + "invisible." + ), + ), + Case( + id="unpacking.double_star_map.duplicate_opaque_undecidable", + definition="def function(a, b): ...", + call="function(1, **kwargs)", + tags={ + "definition_kind": ["pos_or_kw"], + "param_count": "few", + "defaults": "no_default", + "annotations": "unannotated", + "callable_kind": "module_function", + "arg_forms": ["pos_literal", "kw"], + "unpacking": ["double_star_map", "unpack_opaque"], + "arity": "arity_unknown", + "ordering": "order_positional_then_keyword", + "call_target": "target_bare_name", + "source": "same_module_editable", + "inferability": "inferable_partial", + }, + notes=( + "Same shape as the duplicate case but with opaque `**kwargs`: a possible " + "'multiple values for a' collision that cannot be proven. arity_unknown; " + "an autofix/arity rule must bail." + ), + ), +] diff --git a/pyproject.toml b/pyproject.toml index 1516f22..ddb74b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,8 @@ dev = ["pytest>=7.4.0,<8"] [tool.hatch.build.targets.wheel] packages = ["src/linting_arguments_python"] + +[tool.pytest.ini_options] +# Make the top-level `catalog` package importable by the catalog tests without +# packaging it into the wheel (it is test-only fixture data). +pythonpath = ["."] diff --git a/test/test_catalog.py b/test/test_catalog.py new file mode 100644 index 0000000..453bd3b --- /dev/null +++ b/test/test_catalog.py @@ -0,0 +1,98 @@ +"""Structural validation of the function def x call catalog. + +Asserts STRUCTURE ONLY -- never a lint-rule verdict. The catalog is a +verdict-free corpus; a human decides per rule which cases should fire. So this +runner checks that every case is well-formed, uniquely identified, and parses +under the real toolchain (matching the `astroid.extract_node` convention in +`test/tests.py`). Inferability and plugin behaviour are *recorded, not asserted*. + +Run: uv run pytest test/test_catalog.py +""" + +import astroid +import pytest + +from catalog import all_cases +from catalog._schema import marked_source, validate, validity_of +from linting_arguments_python import my_plugin + +CASES = all_cases() +IDS = [case.id for case in CASES] + + +def test_catalog_is_non_empty(): + assert CASES, "no cases collected from catalog modules" + + +def test_ids_are_globally_unique(): + seen = {} + duplicates = [] + for case in CASES: + if case.id in seen: + duplicates.append(case.id) + seen[case.id] = True + assert not duplicates, f"duplicate case ids: {sorted(set(duplicates))}" + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +def test_case_schema_is_valid(case): + errors = validate(case) + assert not errors, "; ".join(errors) + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +def test_case_parses_and_marks_a_call(case): + source = marked_source(case) + validity = validity_of(case) + + if validity == "syntax_error": + with pytest.raises(astroid.AstroidError): + astroid.extract_node(source) + return + + node = astroid.extract_node(source) + # A stray list can come back if multiple `#@` markers slip in; we mark exactly one. + assert not isinstance(node, list), f"{case.id}: expected a single marked node" + assert isinstance(node, astroid.nodes.Call), ( + f"{case.id}: marked node is {type(node).__name__}, expected Call" + ) + + +def test_smoke_record_inferability_and_plugin(capsys): + """Record (do NOT assert) inference + plugin behaviour for every runnable case. + + This surfaces regressions and mis-tagged inferability without baking verdicts + into the corpus. Always passes. View with `pytest -s`. + """ + report = [] + for case in CASES: + if validity_of(case) == "syntax_error": + continue + try: + node = astroid.extract_node(marked_source(case)) + except astroid.AstroidError as exc: + report.append(f"{case.id}: PARSE-FAILED ({exc.__class__.__name__})") + continue + if not isinstance(node, astroid.nodes.Call): + continue + + try: + inferred = next(node.func.infer()) + inferred_kind = type(inferred).__name__ + if inferred is astroid.Uninferable: + inferred_kind = "Uninferable" + except astroid.InferenceError: + inferred_kind = "InferenceError" + + try: + plugin_result = my_plugin.check_call_arguments(function_call=node) + except Exception as exc: # plugin is intentionally incomplete; record only + plugin_result = f"raised {exc.__class__.__name__}" + + report.append( + f"{case.id}: inferred={inferred_kind} " + f"tagged={case.tags.get('inferability')} plugin={plugin_result}" + ) + + print("\n--- catalog smoke report ---") + print("\n".join(report))