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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions catalog/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions catalog/__init__.py
Original file line number Diff line number Diff line change
@@ -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
178 changes: 178 additions & 0 deletions catalog/_schema.py
Original file line number Diff line number Diff line change
@@ -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)
Loading