diff --git a/README.md b/README.md index 707db306cd..2af6ab634f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Pyrefly's current development status is [stable](https://github.com/facebook/pyr - **Fast.** Pyrefly checks over 1.85 million lines of code per second, type checking projects like PyTorch 15x faster than Mypy and Pyright. In the IDE, rechecks typically complete in under 10 milliseconds after saving a file. - **Production-proven at scale.** Pyrefly is the default type checker for Instagram's 20-million-line Python codebase at Meta, and has been adopted by large open source projects including PyTorch and JAX. - **Full-featured language server.** Code navigation, autocomplete, hover information, inlay hints, semantic highlighting, and more, with consistent results across the CLI and your editor of choice. -- **Understands real-world Python.** Built-in support for frameworks like [Pydantic](https://pyrefly.org/en/docs/pydantic/) and [Django](https://pyrefly.org/en/docs/django/), with model validation, field types, and autocomplete that work out of the box. +- **Understands real-world Python.** Built-in support for frameworks and tools like [Pydantic](https://pyrefly.org/en/docs/pydantic/), [Django](https://pyrefly.org/en/docs/django/), and [pytest](https://pyrefly.org/en/docs/pytest/), with model validation, field types, fixture navigation, and autocomplete that work out of the box. - **Adoption-ready.** Migrate from Mypy or Pyright with `pyrefly init`, silence existing errors with `pyrefly suppress`, and generate type annotations with `pyrefly infer`. Start with one file and expand at your own pace. ### Getting Started diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 6165ebf02a..bd3b05ccb3 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -4534,41 +4534,60 @@ impl Server { { return Ok((!actions.is_empty()).then_some(actions)); } - if allow_refactor { - let mut push_refactor_actions = |refactors: Vec| { - for action in refactors { - let mut changes: HashMap> = HashMap::new(); - for (module, edit_range, new_text) in action.edits { - let Some(lsp_location) = self.to_lsp_location(&TextRangeWithModule { - module, - range: edit_range, - }) else { - continue; - }; - changes.entry(lsp_location.uri).or_default().push(TextEdit { - range: lsp_location.range, - new_text, - }); - } - if changes.is_empty() { + let mut push_local_actions = |local_actions: Vec| { + for action in local_actions { + let mut changes: HashMap> = HashMap::new(); + for (module, edit_range, new_text) in action.edits { + let Some(lsp_location) = self.to_lsp_location(&TextRangeWithModule { + module, + range: edit_range, + }) else { continue; - } - actions.push(CodeActionOrCommand::CodeAction(CodeAction { - title: action.title, - kind: Some(action.kind), - edit: Some(WorkspaceEdit { - changes: Some(changes), - ..Default::default() - }), + }; + changes.entry(lsp_location.uri).or_default().push(TextEdit { + range: lsp_location.range, + new_text, + }); + } + if changes.is_empty() { + continue; + } + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: action.title, + kind: Some(action.kind), + edit: Some(WorkspaceEdit { + changes: Some(changes), ..Default::default() - })); + }), + ..Default::default() + })); + } + }; + macro_rules! timed_local_action { + ($name:expr, $call:expr) => {{ + let start = Instant::now(); + if let Some(local_actions) = $call { + push_local_actions(local_actions); } - }; + record_code_action_telemetry($name, start); + }}; + } + if allow_quickfix { + timed_local_action!( + "pytest_fixture_type_annotation", + transaction.pytest_fixture_type_annotation_code_actions( + &handle, + range, + import_format + ) + ); + } + if allow_refactor { macro_rules! timed_refactor_action { ($name:expr, $call:expr) => {{ let start = Instant::now(); if let Some(refactors) = $call { - push_refactor_actions(refactors); + push_local_actions(refactors); } record_code_action_telemetry($name, start); }}; @@ -4637,14 +4656,6 @@ impl Server { "convert_dict", transaction.convert_dict_code_actions(&handle, range) ); - timed_refactor_action!( - "pytest_fixture_type_annotation", - transaction.pytest_fixture_type_annotation_code_actions( - &handle, - range, - import_format - ) - ); let start = Instant::now(); if let Some(action) = convert_module_package_code_actions(&self.initialize_params.capabilities, uri) diff --git a/pyrefly/lib/state/lsp/quick_fixes/pytest_fixture.rs b/pyrefly/lib/state/lsp/quick_fixes/pytest_fixture.rs index 815b8756c2..290b8598ca 100644 --- a/pyrefly/lib/state/lsp/quick_fixes/pytest_fixture.rs +++ b/pyrefly/lib/state/lsp/quick_fixes/pytest_fixture.rs @@ -355,10 +355,6 @@ pub(crate) fn pytest_fixture_type_annotation_code_actions( }); } - if candidates.is_empty() { - return None; - } - let module = module_info.dupe(); let selection_matches_fixtures = candidates .iter() diff --git a/pyrefly/lib/test/lsp/code_actions.rs b/pyrefly/lib/test/lsp/code_actions.rs index 0a1cc10609..055de6c232 100644 --- a/pyrefly/lib/test/lsp/code_actions.rs +++ b/pyrefly/lib/test/lsp/code_actions.rs @@ -4816,6 +4816,40 @@ def test_one(answer: int, user: str): assert_eq!(expected.trim(), updated_all.trim()); } +#[test] +fn pytest_fixture_parameter_annotation_from_annotated_fixture() { + let code = r#" +import pytest # type: ignore + +@pytest.fixture +def enabled() -> bool: + return True + +def test_feature(enabled): + print(enabled) +"#; + let (handles, state) = + mk_multi_file_state_assert_no_errors(&[("main", code)], Require::Everything); + let handle = handles.get("main").unwrap(); + let transaction = state.transaction(); + let module_info = transaction.get_module_info(handle).unwrap(); + let cursor = TextSize::try_from(code.find("enabled):").unwrap()).unwrap(); + let selection = TextRange::new(cursor, cursor); + + let actions = transaction + .pytest_fixture_type_annotation_code_actions(handle, selection, ImportFormat::Absolute) + .unwrap_or_default(); + let action = actions + .iter() + .find(|action| action.title == "Add pytest fixture parameter type annotation") + .expect("missing fixture parameter annotation action"); + let updated = apply_refactor_edits_for_module(&module_info, &action.edits); + assert!( + updated.contains("def test_feature(enabled: bool):"), + "expected fixture parameter annotation from annotated fixture return" + ); +} + /// Returns the edits of the "Add `@override` decorator" quick fix for the method /// at the last `def foo` in `code`, or `None` if the fix is not offered. fn add_override_quickfix_edits( diff --git a/website/docs/pytest.mdx b/website/docs/pytest.mdx new file mode 100644 index 0000000000..802929039e --- /dev/null +++ b/website/docs/pytest.mdx @@ -0,0 +1,159 @@ +--- +title: Pytest Support +description: Pyrefly support for pytest fixtures and test navigation. +--- + +{/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */} + +# Pytest Support + +Pyrefly includes built-in support for [pytest](https://docs.pytest.org/), the +popular Python testing framework. This support focuses on fixtures and IDE +workflows for navigating, annotating, and running tests. + +> **Note:** Pyrefly's pytest support is automatic when a file imports `pytest`. +> Some IDE features also depend on selecting a Python interpreter where pytest is +> installed. + +### Feedback + +We welcome your feedback and suggestions. Please share your thoughts and ideas +by opening an issue on [GitHub](https://github.com/facebook/pyrefly/issues). + +--- + +## What is pytest? + +pytest is a Python testing framework where tests are usually functions or +methods named `test_*`. Its fixture system lets tests request reusable setup +objects by naming fixture parameters. + +```python +import pytest + +@pytest.fixture +def username() -> str: + return "Ada" + +def test_user(username): + assert username == "Ada" +``` + +In this example, pytest injects the `username` fixture into the test function +based on the parameter name. + +--- + +## How Pyrefly Supports pytest + +Pyrefly recognizes pytest imports and fixture decorators without requiring a +plugin or manual configuration. It: + +- Recognizes fixtures declared with `@pytest.fixture`, `@pytest.fixture(...)`, + and imported fixture aliases such as `from pytest import fixture`. +- Resolves fixture parameters in tests and fixtures to their fixture + definitions. +- Finds references from a fixture definition to the test and fixture parameters + that request it. +- Shows optional inlay hints for pytest fixture parameter types. +- Provides quick fixes that add inferred return annotations to fixtures. +- Provides quick fixes that add inferred parameter annotations to tests that use + fixtures. +- Adds runnable CodeLens actions for pytest tests in supported editors. + +--- + +## Fixture Navigation + +Pyrefly understands pytest's fixture-by-name convention, so IDE navigation works +across fixture definitions and fixture parameters in the same module. + +```python +import pytest + +@pytest.fixture +def database_url() -> str: + return "sqlite://" + +def test_connection(database_url): + assert database_url.startswith("sqlite") +``` + +Go-to-definition on the `database_url` parameter in `test_connection` jumps to +the fixture definition. Find-references on the fixture name includes parameters +that request that fixture. + +Pyrefly also handles fixture aliases: + +```python +from pytest import fixture as reusable + +@reusable +def user_id() -> int: + return 42 + +def test_lookup(user_id): + assert user_id > 0 +``` + +--- + +## Fixture Type Hints and Quick Fixes + +Pyrefly can infer fixture return types and offer quick fixes that make those +types explicit. + +```python +import pytest + +@pytest.fixture +def enabled(): + return True + +def test_feature(enabled): + assert enabled +``` + +In the editor, these quick fixes appear as code actions: + +- Place the cursor inside an unannotated fixture to use + `Add pytest fixture type annotation`, which updates the fixture to + `def enabled() -> bool:`. +- Place the cursor on an unannotated test parameter that matches a known fixture + to use `Add pytest fixture parameter type annotation`, which updates the test + to `def test_feature(enabled: bool):`. +- When more than one matching annotation is available, Pyrefly can also offer + batch code actions that annotate all eligible fixtures or fixture parameters + in the file. + +--- + +## Running pytest Tests from the Editor + +In supported editors, Pyrefly shows runnable CodeLens actions above pytest tests. +For example, a test method can be run using a pytest node id such as: + +```bash +python -m pytest path/to/test_file.py::TestClass::test_method +``` + +This feature uses the Python interpreter selected for the workspace. If Pyrefly +cannot import pytest from that interpreter, select the correct interpreter or +install pytest in the active environment. + +--- + +## How to Use + +You do not need to enable a Pyrefly plugin or add Pyrefly-specific configuration +for pytest support. + +1. Install `pytest` in the Python environment used by your editor. +1. Install `pyrefly`. +1. Write pytest fixtures and tests as usual. +1. Run Pyrefly or use the Pyrefly language server in your editor. diff --git a/website/sidebars.ts b/website/sidebars.ts index 62cbedfb1b..47591178aa 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -80,6 +80,11 @@ let docsSidebar = [ }, ], }, + { + type: 'doc' as const, + id: 'pytest', + label: 'Pytest Support', + }, { type: 'doc' as const, id: 'django',