Skip to content

Type the receivers of method calls from the packages' own annotations - #100

Open
MohammadHijjawi97 wants to merge 3 commits into
mainfrom
0.6/matching
Open

MohammadHijjawi97 wants to merge 3 commits into
mainfrom
0.6/matching

Conversation

@MohammadHijjawi97

@MohammadHijjawi97 MohammadHijjawi97 commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Problem

Audit item 2, the biggest under-report: a changed method was matched only when the file names the receiver's class, so most method calls on values a package returns went unflagged.

  • pandas 3.0.6: DataFrame.applymap was removed, NDFrame.swapaxes was removed and DataFrame.groupby(axis=...) are in the diff, but df = pd.read_csv(...); df.applymap(str); df.swapaxes(0, 1); df.groupby("key", axis=0) is not flagged.
  • polars 2.0.0: DataFrame.with_row_count, LazyFrame.melt and GroupBy.count are in the diff, but long = pl.read_csv(...); long.with_row_count(), lf = pl.scan_csv(...); lf.melt(...) and df.group_by("a").count() are not flagged.
  • langchain-core 1.6.9: BaseChatModel.predict was removed is in the diff, but llm = ChatOpenAI(...); llm.predict(q) is not flagged: ChatOpenAI lives in langchain-openai and inherits across packages, and the file never imports langchain-core.

Audit item 8b: pandas.option_context changed from class to function labelled with pd.option_context("mode.copy_on_write", True): old form, although the call is the same code either way.

Change

Item E: receivers typed from the package's own API. No model, nothing run.

  • apidiff.receiver_map(root): from a griffe-loaded release, what the package's annotations say its values are. classes (every public path of a class -> its canonical path, and a class re-exported from another package -> the path imported), bases (canonical paths; another package's as written), returns (a public function under each of its paths, and a method as <class>.<name>, -> the classes its return annotation names: X, X | None, a union and the @overload returns as X|Y, or Self), attrs (properties and annotated attributes) and members (the public names each class declares itself, not inherited). Cached per release and griffe under a new receivers cache folder (RECEIVERS_SCHEMA = 2). Not part of a diff: DIFF_SCHEMA stays 21 and the fixtures are unchanged (scripts/record_diff_fixtures.py --check passes). The first scan of a release loads its sources with griffe a second time for the map (6.6 s for pandas 3.0.6 on this machine, in the main process); every later scan reads the cached map.
  • project.py: scan_file records the evidence (FileUse.bound, accesses, assigned, annotated, chain_keywords, defined: import bindings, access chains with () where a call is made, what each name is assigned or annotated with, the file's own functions' returns, the keywords at the end of a chain, and the names the file's own classes, functions and relative imports define). ApiTypes merges the maps (canonical paths through every package's re-exports, bases, members through the MRO, Self). typed() types each file: a name assigned from a call gets the return annotation's class (pd.read_csv -> DataFrame; a class call -> the class), a chain types each step (df.groupby(...).count() -> DataFrameGroupBy.count), annotated parameters and variables (string annotations too), the file's own functions' return annotations (df = load(p) after def load(p) -> pd.DataFrame, self.make()) and self in a subclass count, and a class is read with its bases across the project's pinned packages, under every public path, so the member pairs match the paths the diff records. A member is read up to the nearest class that defines it in the pinned release, or that the diff says changed it (selection.changed_owners): df.sum() on a DataFrame is DataFrame.sum, which pandas 3 still defines with positional parameters, not NDFrame.sum (whose parameters became keyword-only; the diff reports it on NDFrame alone), and llm.predict is BaseChatModel.predict and not also its base's BaseLanguageModel.predict. FileUse.reaches records the packages a file's values derive from without importing them; Project.code_use includes such files, Project.imports stays strict ("imported by your code" is unchanged).
  • selection.type_project(project, scans, pypi, store): builds the maps for every package whose pinned tree the scan has (and a cached tree of a package the model knows: PyPI.is_cached, never a download) and returns a copy of the project with its files typed, so a second scan of the same project starts afresh. The matching itself (used_names, uses, form) is unchanged for typed receivers.
  • Name-only tier (NAME_ONLY): a member read on a bare name the file shows nothing of (a parameter without an annotation, a loop variable) counts by its name alone when that name is exactly one changed API's across the changed packages the code imports or reaches (a module-level name counts against it) and not in COMMON_NAMES (get, set, run, create, close, update, count, sum, any, all, floor, fetch, execute, ...). A name assigned a literal, a value of a package the maps do not cover (m = re.match(...)), an instance of the file's own class or of a class it imports relatively, a builtin's value (rows = list()) or what the file's own method returns is not such a value. Tagged [name match] in the terminal (label and place), the Markdown summary, the MCP answer and the JSON ("match": "name_only"), with a legend line. By default it is a hint only: out of the notes, and not counted by --fail-on or --annotate github (report.counted). --include-name-matches on scan and sync counts them everywhere (Settings.include_name_matches, ScanResult.name_matches); the block does not keep the choice, which the help says.

Item F: selection.same_call_shape(change): a class <-> function kind change is "uses this API" at a call site, and old form only when the call passes a keyword the new signature does not take (_refused_keywords, from apidiff.keyword_parameters(new_signature): not *args, not the positional-only ones, and None for **kwargs). A subclass or an isinstance() of it, or any other read without a call (form() now takes the use kind), stays the old form: a function is no type. The note is unchanged.

Files outside this item's ownership. engine.py and report.py get more than one-line hooks, so their owners should look: engine.py (one line in Engine.scan to type the project; Settings.include_name_matches, ScanResult.name_matches; used_changes / diff_notes take name_matches; used_apis passes NAME_ONLY through UsedAPI.match and lists name matches last), report.py (the tag in use_text, the label, the Markdown form cell, the legend line, "notes ready" counting only what sync writes, and counted() for fail_reason and github_annotations), cli.py (the flag on scan and sync; receivers in CACHE_NAMESPACES), pypi.py (PyPI.is_cached), apidiff.py (receiver_map, RECEIVERS_SCHEMA, SELF_TYPE, keyword_parameters). PR #80 (file:line locations) edits scan_file's loops and selection._in_file / uses; this change adds separate functions and one keyword line in the FileUse(...) call, so its rebase is small.

Before / after (scratch projects, fresh cache, --cutoff 2025-07-31)

data: requirements.txt pins pandas 3.0.6 and polars 2.0.0; main.py reads a frame with pd.read_csv and calls applymap, swapaxes and groupby("key", axis=0) on it, uses option_context, and with polars pl.DataFrame(...).melt(...), pl.read_csv(...).with_row_count(), pl.DataFrame(...).group_by("a").count() and pl.scan_csv(...).melt(...).

Before (main):

Your code uses 4 APIs that changed after the cutoff (2025-07-31)

pandas 2.3.1 -> 3.0.6
  option_context changed from class to function                                  old form
polars 1.31.0 -> 2.0.0
  DataFrame.melt was removed                                                     old form
  read_csv: batch_size, missing_utf8_is_empty_string, ... were removed           uses this API
  scan_csv: cache, file_cache_ttl, ... were removed                              uses this API

After:

Your code uses 10 APIs that changed after the cutoff (2025-07-31)

pandas 2.3.1 -> 3.0.6
  NDFrame.swapaxes was removed                                                   old form
    main.py   calls swapaxes
  DataFrame.applymap was removed                                                 old form
    main.py   calls applymap
  DataFrame.groupby: axis was removed                                            old form
    main.py   passes axis to groupby
  option_context changed from class to function                                  uses this API
    main.py   calls option_context
polars 1.31.0 -> 2.0.0
  DataFrame.melt was removed                                                     old form
  DataFrame.with_row_count was removed                                           old form
    main.py   calls with_row_count
  GroupBy.count was removed                                                      old form
    main.py   calls count
  LazyFrame.melt was removed                                                     old form
    main.py   calls melt
  read_csv: batch_size, missing_utf8_is_empty_string, ... were removed           uses this API
  scan_csv: cache, file_cache_ttl, ... were removed                              uses this API

lc: pins langchain 1.4.4, langchain-core 1.6.9 and langchain-openai 1.7.0; main.py imports langchain.agents and langchain_openai.ChatOpenAI, never langchain-core, and calls llm.predict(q) on llm = ChatOpenAI(model="gpt-4o").

Before (main):

Your code uses none of the APIs that changed after the cutoff (2025-07-31).
Changed in your dependencies, not seen in your code: 368 changes in 3 packages (langchain 294, langchain-core 72, langchain-openai 2).

After:

Your code uses 1 API that changed after the cutoff (2025-07-31)

langchain-core 0.3.72 -> 1.6.9 (0.3.72 was the latest release at the cutoff; requirements.txt pins 1.6.9)
  BaseChatModel.predict was removed                                              old form
    main.py   calls predict
    Note: `BaseChatModel.predict` was removed; do not use it. Use `BaseChatModel.invoke` instead. [diff + library]

BaseLanguageModel.predict, which the diff reports on its own, is not a second use: BaseChatModel is the nearest class that lost predict. The README's sample project (examples/agent-app) still reports the same 2 APIs.

Review round

The review of the first version found, on real pandas 3.0.6 and synthetic trees, and this round fixes:

  • A subclass's own method inherited its base's change: df.head().T.sum() was reported as NDFrame.sum: ... now keyword-only although DataFrame.sum still takes positional arguments (the diff was right; the widening re-attached it). The receiver map now records the members each class declares, and a member is read up to the nearest class that defines it.
  • The file's own return annotations were recorded but never applied (load() and self.make() never typed a chain). They do now.
  • Values of the file's own classes and methods, of relative imports and of builtins fed the name-only tier (loc = Local(); loc.swapaxes(0, 1) was "NDFrame.swapaxes was removed", a ::warning annotation and exit 3). They are the project's own now.
  • Name matches failed --fail-on and produced GitHub annotations while the docs said only that they were left out of the notes; COMMON_NAMES missed any, floor, sum, fetch and the like.
  • The name's uniqueness was computed over the imported packages but matched against the reached ones too: one bare model.predict was three [name match] uses.
  • class My(pd.option_context) and isinstance(x, pd.option_context) were "uses this API"; option_context(args=1) was not refused (*args was read as a parameter named args), and a ** in a default disabled the check.

Tests

  • tests/test_uses.py: synthetic packages in the repo's style (write_tree, FakePyPI, diff_sources), each with an unrelated call in the same project, asserting exact uses: frames (pandas' shape: a value assigned from read_csv, a constructor through the module, a chain through groupby, an inherited removal, a keyword on a typed receiver, a property; a string annotation; self in a subclass; the file's own return annotations as the only evidence), svc (an overriding subclass: Client.send and Client.foo of its own, the diff's Base.send and Base.foo matched on a Base only), core + plugins (langchain's shape: a class read with its bases across packages, in a file that never imports core; the nearest class that lost the member counts, not its base; a name shared by an imported and a reached package counts for neither), the name-only tier with a positive (applymap) and negatives (count, a common name; melt, two APIs'; an own class, a relative import, a builtin and an own method), item F (option_context: a plain call is "uses this API", a refused keyword and args= the old form, a subclass and an isinstance() the old form, another kind change still the old form) and keyword_parameters.
  • tests/test_scan_first.py: the [name match] tag in the terminal, the legend, the Markdown form cell, the JSON match, the MCP answer, "No notes to write: every use is a name match", fail_reason and github_annotations leaving a name match out until asked, and --include-name-matches on scan and sync through the CLI (--fail-on old-form exits 0 on a name match alone, 3 with the flag).
  • tests/test_project.py: the evidence scan_file records (defined too), and typed() on hand-made maps (canonical paths through re-exports, ancestors, Self, unions, the nearest-definer and nearest-owner rules, own return annotations, reaches, loose, values of other packages and of the file's own code).
  • No fixture changed: the diff records are the same.

Checks: ruff check src tests scripts, ruff format --check src tests scripts, mypy (strict) and pytest -q -p no:cacheprovider -m "not network" on Python 3.13 and 3.10, pytest -m network on 3.13 (record_diff_fixtures.py --check included), and the offline suite with griffe 1.14 on 3.10 (the oldest griffe CI runs).

A changed method was matched only when the file names its class: on a
class it imports, or on a value the file itself shows is an instance of
one. Most calls on values a package returns went unflagged. With pandas
3.0.6, `DataFrame.applymap`, `NDFrame.swapaxes` and `DataFrame.groupby
(axis=)` were in the diff while `df = pd.read_csv(...)` and
`df.applymap(str)` were not flagged; with polars 2.0.0, `with_row_count`,
`LazyFrame.melt` and `GroupBy.count` on frames from `pl.read_csv` and
`pl.scan_csv`; with langchain-core 1.6.9, `BaseChatModel.predict` on
`llm = ChatOpenAI(...)`, whose class lives in langchain-openai and
inherits across packages, in a file that never imports langchain-core.

The receivers are now typed from the pinned releases' own annotations,
read statically with griffe (apidiff.receiver_map) and cached per
release: a name assigned from a call gets the class the return
annotation names, a chain types each step, annotated parameters and the
file's own functions' returns count, and a class is read with its bases
across the project's pinned packages, under every public path, up to
the nearest class the diff says changed the member. scan_file records
the evidence (FileUse.bound, accesses, assigned, annotated,
chain_keywords); project.typed applies it; selection.type_project builds
the maps for the trees the scan has and gives Engine.scan a typed copy
of the project. A file whose values derive from a package it does not
import uses that package's API too (FileUse.reaches, Project.code_use).

Where the receiver cannot be typed, a member whose name is exactly one
changed API's across the imported packages, and not a common Python
name, counts by its name alone: the reports tag the use "[name match]",
and its note goes into the block only with --include-name-matches.

A class that became a function, or the reverse (pandas 3's
option_context), is called the same way: a call of it is "uses this
API" unless it passes a keyword the new signature does not take.
…of CI

The review of the typed receivers found, on real pandas 3.0.6 and on
synthetic trees:

- A subclass's own method inherited its base's change: typed() widened a
  receiver to every base class up to the nearest one the diff changed, so
  `df.head().T.sum()` was "NDFrame.sum: axis, ... now keyword-only" while
  pandas 3 still defines `DataFrame.sum` with positional parameters (the
  diff itself reports the change on NDFrame alone). receiver_map now
  records the members each class declares (RECEIVERS_SCHEMA 2) and widen()
  stops at the nearest class that defines the member, or that the diff
  says changed it. A member the file's own subclass defines is not widened.
- The file's own return annotations (`load()`, `self.make()`) were recorded
  but unreachable in resolve(): they type the chain now.
- Values of the file's own classes and methods, of relative imports and of
  builtins fed the name-only tier (`loc = Local(); loc.swapaxes(0, 1)` was
  "NDFrame.swapaxes was removed", a warning annotation and exit 3).
  FileUse.defined records those names; typed() treats their values as the
  project's own.
- A name match changed --fail-on's exit code and produced GitHub
  annotations although the docs said only that it was left out of the
  notes. report.counted() leaves it out of both unless
  --include-name-matches is given, and COMMON_NAMES gains the generic
  names (`any`, `all`, `sum`, `floor`, `fetch`, `execute`, ...).
- The name's uniqueness was computed over the imported packages but
  matched against the reached ones too: one bare `model.predict` was three
  "[name match]" uses. type_project types once to learn what each file
  reaches, then keeps the names unique across imported and reached
  packages.
- A subclass or an isinstance() of a class that became a function was
  "uses this API": form() takes the use kind and keeps the old form for a
  reference. `option_context(args=1)` was not refused because `*args` read
  as a parameter named args, and a `**` in a default disabled the check:
  apidiff.keyword_parameters tells `*`, `**` and `/` apart.

The CHANGELOG bullet is cut to what users notice; the mechanism, the cost
of the first scan of a release and the CI behaviour of a name match are in
docs/how-it-works.md and the README limitation (four languages).
@MohammadHijjawi97

Copy link
Copy Markdown
Owner Author

Review round applied in 8dae090:

  • A member is read up to the nearest class that defines it in the pinned release (the receiver map records each class's own members, RECEIVERS_SCHEMA 2): df.head().T.sum() on pandas 3.0.6 is DataFrame.sum, no longer NDFrame.sum. A member the file's own subclass defines is not widened to the bases either.
  • The file's own return annotations (load(), self.make()) now type the chain; they were recorded but unreachable in resolve().
  • Instances of the file's own classes, of relative imports and of builtins, and what its own methods return, are the project's own: no name match on them (FileUse.defined).
  • A name match is a hint by default: out of --fail-on and --annotate github as well as of the notes, unless --include-name-matches is given (report.counted). COMMON_NAMES gains any, all, sum, floor, fetch, execute and the like.
  • The name's uniqueness is computed over the imported and the reached packages (type_project types once to learn what each file reaches).
  • A subclass or an isinstance() of a class that became a function stays old form (form() takes the use kind); apidiff.keyword_parameters tells *args, / and **kwargs apart, so option_context(args=1) is refused and a ** in a default no longer disables the check.
  • CHANGELOG cut to the user-visible facts; docs/how-it-works.md says how, what the first scan of a release costs, and what a name match does in CI; the PR body now says which engine.py and report.py edits go beyond hooks.

Not done: reusing the diff's griffe load for the receiver map (a separate refactor; the map is cached per release after the first scan).

A verification of the typed receivers found:

- The file's own return annotations were keyed by bare name: every
  method's `make()` and `self.make()` for every class. With `A.make() ->
  fr.Frame` and `B.make() -> dict`, B's `self.make().swapaxes(0, 1)` was
  NDFrame.swapaxes (a path match), and a method `A.load() -> fr.Frame`
  typed a module-level `load()`. A function's return now types a call of
  its own name only, and `self.m()` the return of the `m` the reading
  class (or an in-file base) defines; when the classes that read it
  disagree, or one reaches no `m` in the file, it types nothing.
- The guard against widening a member the file defines used every name
  the file defines, so an unrelated module-level `def predict` hid
  `self.predict()` in `class Mine(ChatX)`. FileUse.shadowed records the
  members each class (and its in-file bases) defines over its imported
  bases, and only those stop the widening.
- The name-only tier was mostly noise on ordinary code with pandas
  imported (`arr.shape`, `obj.to_dict()`, `args.verbose`, `scores.rank()`,
  `a_set.union()` as polars' Enum.union, `dt.fromisoformat()` as pandas'
  Timestamp.fromisoformat). Name matches are now hidden from every
  report (terminal, report.md, the JSON lists, MCP answers), the notes
  and CI unless --include-name-matches (include_name_matches over MCP)
  asks for them. The members of the common built-in and standard-library
  types (from dir() at import) never count by name, COMMON_NAMES gains
  shape, view, to_dict, to_json, labels, verbose and rank, and a value
  the file assigns from its own or a builtin value is not unknown:
  aliases, `store = self.store`, tuple unpacking, `:=`, `except ... as`.
- report.counted() filtered per API, so an API with one path match
  brought its name-only places into --annotate (`def tidy(frame):
  frame.swapaxes(0, 1)` got a warning). It filters per use.
- A bare reference to a class that became a function (`ctx =
  option_context`) was the old form although valid with the function.
  Only a subclass, an isinstance()/issubclass() of it (FileUse.type_refs)
  or an attribute a function does not have is the old form now.

docs/how-it-works.md, the README limitation (four languages) and the
CHANGELOG bullet say that name matches are hidden unless asked for.
@MohammadHijjawi97

Copy link
Copy Markdown
Owner Author

Pushed fixes for what the verification of 8dae090 found:

  • Return annotations scoped: a module-level function's return types only a call of its own name; self.m() takes the return of the m the reading class (or an in-file base) defines. B.make() -> dict no longer picks up A.make() -> fr.Frame, and a method A.load() no longer types a module-level load().
  • Shadowing per class: only members the receiver's own class (and its in-file bases) define stop the widening to the imported base (FileUse.shadowed). An unrelated def predict no longer hides self.predict() in class Mine(ChatX).
  • Name matches hidden by default: they no longer appear in the terminal, report.md, the JSON lists, MCP answers, the notes or CI unless --include-name-matches (CLI) / include_name_matches (MCP project_changes) asks for them. Members of common built-in and stdlib types (from dir()) never count by name, COMMON_NAMES gains shape, view, to_dict, to_json, labels, verbose and rank, and values assigned from the file's own or builtin values (aliases, self.store, tuple unpacking, :=, except ... as) are not "unknown".
  • Per-use CI counting: report.counted() filters uses, not APIs, so a path match elsewhere no longer brings name-only places into --annotate.
  • Class turned function: a bare reference (ctx = option_context) is "uses this API"; only a subclass, an isinstance()/issubclass() or an attribute a function lacks is the old form.

Docs (how-it-works, README in four languages) and the CHANGELOG bullet are updated. Each fix has a test that fails without it. Gate: ruff, ruff format, mypy and pytest (-m "not network") green on Python 3.13 and 3.10.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant