Skip to content

[ty] Add "Find references" support for pytest fixtures - #28075

Open
lerebear wants to merge 1 commit into
lerebear/push-tvrmynppplvqfrom
lerebear/push-ozuqtklrqmnm
Open

[ty] Add "Find references" support for pytest fixtures#28075
lerebear wants to merge 1 commit into
lerebear/push-tvrmynppplvqfrom
lerebear/push-ozuqtklrqmnm

Conversation

@lerebear

@lerebear lerebear commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

This adds "Find References" support for pytest fixtures. It connects fixture declarations to imports and reexports that expose them, fixture requests in tests and fixture functions, and the requests' lexical uses. Searches can start from any of those locations and respect fixture visibility and shadowing across class hierarchies, modules, applicable conftest.py files, and fixtures registered by pytest's installed core plugins.

For example, given:

# src/test_example.py
import pytest

@pytest.fixture(name="resource")
def implementation(): ...

copy = implementation

@pytest.fixture
def dependent(resource):
    print(resource)

def test_use(resource):
    print(resource)

Finding references on implementation reports its ordinary Python alias together with requests for its public fixture name, resource, and the requests' uses:

info[references]: Found 6 references
  --> src/test_example.py:5:5
   |
 5 | def implementation(): ...
   |     --------------
 6 |
 7 | copy = implementation
   |        --------------
 8 |
 9 | @pytest.fixture
10 | def dependent(resource):
   |               --------
11 |     print(resource)
   |           --------
12 |
13 | def test_use(resource):
   |              --------
14 |     print(resource)
   |           --------

Closes astral-sh/ty#4115.

Approach

The fixture reference search proceeds as follows:

  1. We map the selected symbol to a set of fixture function definitions that form its reference identity. For instance, we map a test parameter to the definition of the fixture function that it requests.
  2. We find the function declarations, imports, and fixture requests related to the identity set in each eligible file. For example, we find all other test parameters that request a particular fixture.
  3. Finally, we use the existing semantic reference visitor to find identifiers that resolve to one of those declarations, imports, or fixture requests.

Fixture references can involve several identifier names for the same fixture, but walking a file once per name would be expensive. As a result, this change introduce a LocalReferenceSearches data type which groups those searches such that each file's syntax tree is still traversed only once. Its Single variant keeps ordinary one-name reference searches on the existing direct-comparison path without allocating a hash map, but fixture searches that need multiple names use Multiple for fast lookup during the shared traversal.

Test Plan

See included tests.

@astral-sh-bot astral-sh-bot Bot added server Related to the LSP server ty Multi-file analysis & type inference labels Aug 26, 2026
lerebear added a commit that referenced this pull request Aug 26, 2026
…8071)

<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
- Does this PR follow our AI policy
(https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)?
-->

## Summary

In anticipation of ["find references" for pytest
fixtures](#28075), which
introduces many more speculative attempts at resolving pytest fixtures,
this PR helps preserve efficiency by short-circuiting a few Salsa
queries:

- Return from `fixture_declaration` before calling
`function_known_decorators` when a function has no decorators.
- Return from `fixture_candidates_from_definition` before calling
`exists_at_runtime` when the definition kind cannot expose a fixture.
- Return from `directly_parametrized` before calling
`function_known_decorators` when the test function has no decorators
(while still checking parametrization from an enclosing class).

These are all behavior-preserving early exits.

## Test Plan

This is a refactor that relies on existing test coverage.
<!-- How was it tested? -->
@lerebear
lerebear force-pushed the lerebear/push-ozuqtklrqmnm branch from 595146d to 7791fb7 Compare August 26, 2026 21:24
lerebear added a commit that referenced this pull request Aug 26, 2026
…28073)

<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
- Does this PR follow our AI policy
(https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)?
-->

## Summary

In anticipation of ["find references" for pytest
fixtures](#28075), which
enumerates possible fixture requests across every parameter of a
function, this PR factors the shared, function-level work in
`fixture_request_for_parameter` into `FixtureRequestContext`.

This lets the follow-up construct one context per function and reuse it
for each candidate parameter, rather than repeatedly calling:

- `fixture_declaration`, `is_collected_test`, and
`is_unittest_test_case` to determine whether the function can request
fixtures.
- `infer_definition_types` to retrieve the function type.
- `mock_patch_count` to determine how many parameters are supplied by
`unittest.mock.patch`.

The existing `fixture_request_for_parameter` entry point now constructs
a context and delegates to it, preserving behavior for existing callers.
This is a behavior-preserving refactor.

## Test Plan

This is a refactor that relies on existing test coverage (although I
have backfilled a new test for some pre-existing behaviour).
<!-- How was it tested? -->
@lerebear
lerebear force-pushed the lerebear/push-ozuqtklrqmnm branch from 7791fb7 to 4e6a752 Compare August 26, 2026 21:40
@lerebear
lerebear force-pushed the lerebear/push-ozuqtklrqmnm branch from 4e6a752 to 8d91d99 Compare August 26, 2026 21:43
@lerebear
lerebear force-pushed the lerebear/push-ozuqtklrqmnm branch from 8d91d99 to d9bfeb3 Compare August 26, 2026 23:37
@lerebear
lerebear force-pushed the lerebear/push-ozuqtklrqmnm branch from d9bfeb3 to 784d39b Compare August 27, 2026 05:20
@astral-sh-bot

astral-sh-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 97.79%. The percentage of expected errors that received a diagnostic held steady at 94.33%. The number of fully passing files held steady at 112/136.

@astral-sh-bot

astral-sh-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot

astral-sh-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff (timing results)

@lerebear
lerebear requested a review from MichaReiser August 27, 2026 05:41
@lerebear
lerebear marked this pull request as ready for review August 27, 2026 05:41
@lerebear
lerebear requested review from a team as code owners August 27, 2026 05:41
Comment on lines +287 to +295
let minimum_job_len = minimum_parallel_job_len(files.len(), MAX_MIN_FILES_PER_PARALLEL_JOB);
let references = files
.into_par_iter()
.with_min_len(minimum_job_len)
.map_with_db(db, |db, file| {
pytest_fixture_references_for_file(db, file, &fixture_identities, mode)
})
.flat_map_iter(|references| references)
.collect::<Vec<_>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems unfortunate to iterate over all files here, only to then iterate over all files again to find ordinary references. On large projects, this requires repeating a lot of work. It can also trash Salsa caches because most eviction algorithms aren't scan-resistent.

  • Clone the db for each batch: The cloning itself is cheap, but droping a clone isn't because it requires some coordination work.
  • Read the source_text of each file. This is where we start trashing our cache. This isn't the case today, but we could have a compressed source text and a recent-used uncompressed cache. Iterating twice will kill any form of recency
  • Run a memchr search across the source
  • Run a local reference finder

Can you say more why you decided for this split?

Comment on lines +2364 to +2380
assert_snapshot!(test.references(), @"
info[references]: Found 5 references
--> src/fixtures.py:5:5
|
5 | def resource(): ...
| --------
|
::: src/test_example.py:2:22
|
2 | from fixtures import resource as alias
| -------- -----
3 |
4 | def test_use(alias):
| -----
5 | print(alias)
| -----
");

@MichaReiser MichaReiser Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for being a bit late on picking up on this. I think the implementation you have here makes sense, given how the pytest go-to-definition is defined, but it's inconsistent with how find references and go-to-definition work outside pytest. Reviewing the PRs in isolation never fully consider the LSP experience.

If we use this example (https://play.ty.dev/85c8eeb6-b0b5-4f4a-8779-b1928bacda63) without pytest support and search all references of def resource, it only returns def resource and the from fixtures import resource as alias. That makes sense to me, because alias is a new and independent definition. Renaming resource shouldn't rename alias, and the opposite is also true, renaming alias shouldn't rename resource.

The same behavior applies to goto definition. Clicking on alias in print(alias) jumps to the import statement. This is where the name is defined. You can then jump to resource, which jumps to the def resource(...).

Now, I can see that pytest fixtures are a bit different, but only if we enforce that alias has to point to a proper fixture for go to definition to offer it as a target. And I wonder if this is something we should do in the first place. Let's say you import a name which isn't a fixture and you run your test. Pytest then fails because you forgot to mark the imported symbol as a fixture. It would then be rather annoying if goto definition doesn't work.

That's why I think we should actually simplify pytest go to definition by simply implementing Pytest's name lookup. For functions that look like pytest functions, use it's name lookup to find the name in the enclosing scope and jump to it regardless on whether it is a pytest fixture or not. Don't resolve through aliases, matching our normal go to behavior. Clicking on an imported symbol also implements pytest resolution in test files.

This then also allows find references to work more like normal find references. And rename should also fit more naturally.

This might be worth talking about in person.

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

Labels

server Related to the LSP server ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants