From 475be0eb425ff42873cb268999c86a64f4ea3ff8 Mon Sep 17 00:00:00 2001 From: Julien Pillaud Date: Wed, 13 May 2026 11:39:04 +0200 Subject: [PATCH 1/3] feat: allow @fixture without parentheses when no arguments --- docs/best-practices.md | 32 +++++++------- docs/core-concepts/dependency-injection.md | 10 ++--- docs/core-concepts/factories.md | 5 ++- docs/core-concepts/fixtures.md | 49 +++++++++++----------- docs/core-concepts/sessions-and-suites.md | 6 +-- docs/core-concepts/tags.md | 12 +++++- docs/core-concepts/tests.md | 19 +++++---- docs/faq.md | 14 +++++-- docs/getting-started/quickstart.md | 2 +- docs/guides/pytest-to-protest.md | 30 ++++++------- docs/guides/testing-fastapi.md | 6 +-- docs/index.md | 4 +- docs/internals/dependency-injection.md | 22 +++++----- protest/di/decorators.py | 22 +++++++++- protest/fixtures/builtins.py | 6 +-- 15 files changed, 139 insertions(+), 100 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 674993f2..66e1ca88 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -61,10 +61,10 @@ session.add_suite(integration_suite) # DON'T DO THIS - 500 lines of mixed fixtures/suites/tests session = ProTestSession() -@fixture() +@fixture def db(): ... -@fixture() +@fixture def cache(): ... session.bind(db) @@ -91,7 +91,8 @@ import tests.test_users # noqa: F401, E402 import tests.test_orders # noqa: F401, E402 ``` -The imports are unused — they exist only to trigger `@suite.test()` registration at import time. This requires `# noqa` to silence linters and makes dependencies invisible. See the [Project Organization guide](guides/project-organization.md) for the correct pattern. +The imports are unused — they exist only to trigger `@suite.test()` registration at import time. This requires `# noqa` to silence linters and makes dependencies invisible. See +the [Project Organization guide](guides/project-organization.md) for the correct pattern. ## Suite Design @@ -160,11 +161,11 @@ di_suite = ProTestSuite( ### Scope Determines Placement -| Scope | Where to Define | Use Case | -|-------|-----------------|----------| -| Session | `session.py` or `fixtures.py` | Database, expensive resources | -| Suite | Suite file | Suite-specific setup | -| Test | Near test, `@fixture()` | Test isolation, cheap resources | +| Scope | Where to Define | Use Case | +|---------|-------------------------------|---------------------------------| +| Session | `session.py` or `fixtures.py` | Database, expensive resources | +| Suite | Suite file | Suite-specific setup | +| Test | Near test, `@fixture` | Test isolation, cheap resources | ### Session Fixtures: Expensive Resources @@ -189,7 +190,7 @@ from protest import ProTestSuite, fixture api_suite = ProTestSuite("API") -@fixture() +@fixture async def authenticated_client( db: Annotated[Database, Use(database)] ) -> AsyncGenerator[APIClient, None]: @@ -204,8 +205,8 @@ api_suite.bind(authenticated_client) ### Test Fixtures: Fresh Per Test ```python -# Use @fixture() (not scoped) for per-test isolation -@fixture() +# Use @fixture (not scoped) for per-test isolation +@fixture def request_payload() -> dict: return {"name": "test", "value": 42} ``` @@ -231,6 +232,7 @@ suite.bind(user) ``` Usage: + ```python @suite.test() async def test_user_permissions( @@ -284,13 +286,13 @@ ProTestSuite("Misc") # Bad ```python # Name after what they provide -@fixture() +@fixture def database(): ... # Provides a database @factory() def user(): ... # Provides a user (becomes user_factory in tests) -@fixture() +@fixture def api_client(): ... # Provides an API client ``` @@ -435,12 +437,12 @@ def convert_video(input_path: str, output_path: str) -> ConversionResult: ```python # BAD: Should be test-scoped but bound to session -@fixture() +@fixture def request_id(): ... session.bind(request_id) # Fresh value needed per test! # GOOD: Test-scoped (not bound) -@fixture() +@fixture def request_id() -> str: return str(uuid4()) ``` diff --git a/docs/core-concepts/dependency-injection.md b/docs/core-concepts/dependency-injection.md index 2aba9c22..eed0d8e3 100644 --- a/docs/core-concepts/dependency-injection.md +++ b/docs/core-concepts/dependency-injection.md @@ -12,7 +12,7 @@ from protest import ProTestSession, Use, fixture session = ProTestSession() -@fixture() +@fixture def database(): return Database() @@ -47,11 +47,11 @@ def undefined_fixture(): Fixtures can depend on other fixtures: ```python -@fixture() +@fixture def config(): return {"db_url": "postgres://localhost"} -@fixture() +@fixture async def database(cfg: Annotated[dict, Use(config)]): return await connect(cfg["db_url"]) @@ -91,11 +91,11 @@ If two tests both use `database`, and `database` is session-scoped, they share t Raised when a fixture depends on a narrower scope: ```python -@fixture() # Test scope (not bound) +@fixture # Test scope (not bound) def per_test(): return "fresh" -@fixture() +@fixture def shared(x: Annotated[str, Use(per_test)]): return x diff --git a/docs/core-concepts/factories.md b/docs/core-concepts/factories.md index 29309d00..983bde33 100644 --- a/docs/core-concepts/factories.md +++ b/docs/core-concepts/factories.md @@ -38,7 +38,8 @@ Key points: - Call the factory with `await` — it's always async - Each call can pass different arguments -> **Warning: Factory calls are always async**, even if the factory function itself is `def` (not `async def`). This means your test **must** be `async def` if it calls a factory. Internally, `FixtureFactory.__call__` is async because it uses `asyncio.Lock` for thread-safe caching. +> **Warning: Factory calls are always async**, even if the factory function itself is `def` (not `async def`). This means your test **must** be `async def` if it calls a factory. Internally, `FixtureFactory.__call__` is async because it +> uses `asyncio.Lock` for thread-safe caching. ### Common Mistake: Calling a Factory From a Sync Test @@ -129,7 +130,7 @@ If a test creates `alice` then `bob`, teardown runs `bob` first, then `alice`. Factories can depend on other fixtures: ```python -@fixture() +@fixture def database(): return Database() diff --git a/docs/core-concepts/fixtures.md b/docs/core-concepts/fixtures.md index e0383ecd..f3e0e2a2 100644 --- a/docs/core-concepts/fixtures.md +++ b/docs/core-concepts/fixtures.md @@ -4,12 +4,12 @@ Fixtures provide reusable setup and teardown logic for tests. ## What is a Fixture? -A fixture is a function decorated with `@fixture()` that provides a value to tests. +A fixture is a function decorated with `@fixture` that provides a value to tests. ```python from protest import fixture -@fixture() +@fixture def config(): return {"debug": True} ``` @@ -18,7 +18,7 @@ def config(): **Important:** Fixture scope is determined by WHERE you bind it, not by the decorator. -The `@fixture()` decorator only marks a function as a fixture. The scope is set when you bind the fixture to a session or suite. +The `@fixture` decorator only marks a function as a fixture. The scope is set when you bind the fixture to a session or suite. ### Session Scope @@ -27,7 +27,7 @@ Bind to session with `session.bind()`. Lives for the entire test session. ```python from protest import fixture, ProTestSession -@fixture() +@fixture async def database(): db = await connect() yield db @@ -46,7 +46,7 @@ Bind to suite with `suite.bind()`. Lives for the duration of the suite. ```python from protest import fixture, ProTestSuite -@fixture() +@fixture def api_client(db: Annotated[Database, Use(database)]): return Client(db) @@ -61,7 +61,7 @@ Use case: Resources shared within a group of related tests. Don't bind the fixture. Fresh instance for each test. ```python -@fixture() +@fixture def request_id(): return str(uuid.uuid4()) @@ -72,18 +72,18 @@ Use case: Isolated state per test, unique IDs, temporary files. ## Summary -| Binding | Scope | Caching | -|---------|-------|---------| -| `session.bind(fn)` | SESSION | One instance for entire session | -| `suite.bind(fn)` | SUITE | One instance per suite execution | -| No binding | TEST | Fresh instance per test | +| Binding | Scope | Caching | +|--------------------|---------|----------------------------------| +| `session.bind(fn)` | SESSION | One instance for entire session | +| `suite.bind(fn)` | SUITE | One instance per suite execution | +| No binding | TEST | Fresh instance per test | ## Teardown with yield Use `yield` to separate setup from teardown: ```python -@fixture() +@fixture async def database(): # Setup db = await connect() @@ -102,20 +102,20 @@ Teardown runs in reverse order (LIFO). If multiple fixtures are used, the last o A fixture can only depend on fixtures with equal or wider scope: -| Fixture Scope | Can Depend On | -|---------------|---------------| -| Session | Session only | -| Suite | Session, parent suites, same suite | -| Test | Anything | +| Fixture Scope | Can Depend On | +|---------------|------------------------------------| +| Session | Session only | +| Suite | Session, parent suites, same suite | +| Test | Anything | Violating this raises `ScopeMismatchError`: ```python -@fixture() +@fixture def per_test_data(): return "per-test" -@fixture() +@fixture def shared_resource(x: Annotated[str, Use(per_test_data)]): return x @@ -144,6 +144,7 @@ This works transitively: if fixture A depends on fixture B with tag "x", tests u ## Limiting Concurrent Access with max_concurrency Some fixtures wrap resources that have limited concurrent access: + - Rate-limited APIs (e.g., max 2 requests/second) - Connection pools with fixed capacity - License-restricted resources @@ -210,7 +211,7 @@ async def rate_limited_api(): """Only 2 concurrent requests allowed.""" yield ApiClient() -@fixture() +@fixture async def user_service(api: Annotated[ApiClient, Use(rate_limited_api)]): """Service that uses the rate-limited API.""" yield UserService(api) @@ -242,7 +243,7 @@ Autouse fixtures are automatically resolved at their scope start, without being ### Session Autouse ```python -@fixture() +@fixture def configure_logging(): logging.basicConfig(level=logging.DEBUG) yield @@ -256,7 +257,7 @@ Session autouse fixtures are resolved at `SESSION_SETUP_START`, before any test ### Suite Autouse ```python -@fixture() +@fixture def clear_environment(): old = os.environ.copy() os.environ.clear() @@ -280,10 +281,10 @@ Don't use autouse when tests need the fixture's value - use explicit `Use()` ins ## Plain Functions -All fixtures must be decorated with `@fixture()`. Plain functions raise `PlainFunctionError` when used with `Use()`: +All fixtures must be decorated with `@fixture`. Plain functions raise `PlainFunctionError` when used with `Use()`: ```python -def not_a_fixture(): # Missing @fixture()! +def not_a_fixture(): # Missing @fixture! return "value" @session.test() diff --git a/docs/core-concepts/sessions-and-suites.md b/docs/core-concepts/sessions-and-suites.md index 5daa7bd6..f4603624 100644 --- a/docs/core-concepts/sessions-and-suites.md +++ b/docs/core-concepts/sessions-and-suites.md @@ -39,7 +39,7 @@ Bind fixtures to the session with `bind()`: ```python from protest import fixture -@fixture() +@fixture def database(): db = connect() yield db @@ -79,7 +79,7 @@ api_suite = ProTestSuite( Bind fixtures to a suite with `bind()`: ```python -@fixture() +@fixture def api_client(): return Client() @@ -124,7 +124,7 @@ orders_suite.full_path # "API::Orders" Child suites can access fixtures from parent suites: ```python -@fixture() +@fixture def api_client(): return Client() diff --git a/docs/core-concepts/tags.md b/docs/core-concepts/tags.md index 27514096..a66661a4 100644 --- a/docs/core-concepts/tags.md +++ b/docs/core-concepts/tags.md @@ -46,7 +46,7 @@ session.bind(db) # Fixture that depends on db - inherits "database" tag -@fixture() +@fixture def user_repository(db: Annotated[Database, Use(db)]): return UserRepository(db) @@ -54,7 +54,7 @@ session.bind(user_repository) # Fixture that depends on user_repository - also inherits "database" -@fixture() +@fixture def user_service(repo: Annotated[UserRepository, Use(user_repository)]): return UserService(repo) @@ -68,6 +68,7 @@ async def test_create_user(svc: Annotated[UserService, Use(user_service)]): ``` The dependency chain is: + ``` test_create_user → user_service → user_repository → db (tagged "database") ``` @@ -77,6 +78,7 @@ So `test_create_user` inherits the "database" tag without any explicit declarati ### Why This Matters **Without tag inheritance:** + ```python # You must manually tag EVERY test that touches the database @session.test(tags=["database"]) # Easy to forget! @@ -90,6 +92,7 @@ async def test_update_user(): ... ``` **With tag inheritance:** + ```python # Tag the fixture ONCE @fixture(tags=["database"]) @@ -175,6 +178,7 @@ protest tags list tests:session ``` Output: + ``` api database @@ -189,6 +193,7 @@ protest tags list tests:session -r ``` Output: + ``` test_create_user: api, database test_delete_user: api, database @@ -214,6 +219,7 @@ def storage(): ... ``` Run tests without external dependencies: + ```bash protest run tests:session --no-tag database --no-tag redis --no-tag s3 ``` @@ -229,6 +235,7 @@ async def test_full_migration(): ``` Quick feedback loop: + ```bash protest run tests:session --no-tag slow ``` @@ -246,6 +253,7 @@ async def test_deployment(): ... ``` Run locally (no Docker): + ```bash protest run tests:session --no-tag requires-docker ``` diff --git a/docs/core-concepts/tests.md b/docs/core-concepts/tests.md index 09b173bf..fa642cee 100644 --- a/docs/core-concepts/tests.md +++ b/docs/core-concepts/tests.md @@ -36,7 +36,7 @@ Tests declare their dependencies using type annotations: from typing import Annotated from protest import Use, fixture -@fixture() +@fixture def database(): return Database() @@ -175,7 +175,7 @@ ProTest evaluates skip conditions **after** fixture resolution, so your callable from typing import Annotated from protest import Use, fixture -@fixture() +@fixture def environment(): return {"is_ci": os.getenv("CI") == "true"} @@ -191,6 +191,7 @@ def test_local_only(env: Annotated[dict, Use(environment)]): ``` **How it works:** + 1. Fixtures are resolved for the test 2. ProTest introspects the skip callable's signature 3. Matching fixtures are passed as kwargs to the callable @@ -299,14 +300,14 @@ async def test_resilient(): When combining options: -| Combination | Behavior | -|-------------|----------| -| `skip + xfail` | Skip takes priority (test not executed) | -| `skip + retry` | Skip takes priority | +| Combination | Behavior | +|--------------------------|-----------------------------------------------| +| `skip + xfail` | Skip takes priority (test not executed) | +| `skip + retry` | Skip takes priority | | `skip(callable) + xfail` | Skip evaluated first; if skips, xfail ignored | -| `skip(callable) + retry` | Skip evaluated first; if skips, no retry | -| `xfail + retry` | Retry first, then xfail/xpass evaluation | -| `timeout + retry` | Timeout triggers retry | +| `skip(callable) + retry` | Skip evaluated first; if skips, no retry | +| `xfail + retry` | Retry first, then xfail/xpass evaluation | +| `timeout + retry` | Timeout triggers retry | ## Output Capture diff --git a/docs/faq.md b/docs/faq.md index f46c9cf2..5951e56f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,10 +29,11 @@ from typing import Annotated user_suite = ProTestSuite("User") -@fixture() +@fixture def user(): return User("alice") + user_suite.bind(user) # SUITE scope @@ -95,16 +96,22 @@ If fixtures could use `From()`, you'd get hidden cartesian products: ```python # Hypothetical - NOT SUPPORTED (decorator syntax doesn't exist either) # If this were possible: -@fixture() +@fixture def db(engine: Annotated[str, From(ENGINES)]): # 3 engines ... + + session.bind(db) -@fixture() + +@fixture def user(role: Annotated[str, From(ROLES)], db: ...): # 2 roles ... + + session.bind(user) + @session.test() def test_perms(method: Annotated[str, From(METHODS)], user: ...): # 4 methods ... @@ -150,6 +157,7 @@ async def test_ffmpeg_conversion() -> None: ``` The `Shell` helper: + - Runs subprocesses with isolated pipes (no fd sharing issues) - Automatically prints output for ProTest to capture - Works safely with concurrent tests (`-n 4`) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c33ba08a..a01ca775 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -21,7 +21,7 @@ session.add_suite(api_suite) # Define a test-scoped fixture (fresh instance per test) # For session scope: session.bind(fixture_fn) # For suite scope: suite.bind(fixture_fn) -@fixture() +@fixture def config(): return {"api_url": "https://api.example.com"} diff --git a/docs/guides/pytest-to-protest.md b/docs/guides/pytest-to-protest.md index 5be1c0b9..34d7b911 100644 --- a/docs/guides/pytest-to-protest.md +++ b/docs/guides/pytest-to-protest.md @@ -4,13 +4,13 @@ This guide helps you migrate existing pytest test suites to ProTest. It covers t ## Philosophy Shift -| pytest | ProTest | -|--------|---------| -| Convention-based (discover `test_*.py`) | Explicit structure (Suites, Sessions) | -| Global fixtures via conftest.py | Explicit binding with `suite.bind()` | -| Implicit dependency injection | Explicit DI with `Annotated[T, Use(fixture)]` | -| Parametrize with `@pytest.mark.parametrize` | Parametrize with `ForEach` + `From` | -| Scope via `@pytest.fixture(scope=...)` | Scope via binding: `session.bind()` / `suite.bind()` | +| pytest | ProTest | +|---------------------------------------------|------------------------------------------------------| +| Convention-based (discover `test_*.py`) | Explicit structure (Suites, Sessions) | +| Global fixtures via conftest.py | Explicit binding with `suite.bind()` | +| Implicit dependency injection | Explicit DI with `Annotated[T, Use(fixture)]` | +| Parametrize with `@pytest.mark.parametrize` | Parametrize with `ForEach` + `From` | +| Scope via `@pytest.fixture(scope=...)` | Scope via binding: `session.bind()` / `suite.bind()` | ## Quick Reference @@ -58,11 +58,11 @@ def test_user_in_db(user, db): from protest import fixture, Use from typing import Annotated -@fixture() +@fixture def user(): return User(name="test") -@fixture() +@fixture def db(): conn = connect() yield conn # teardown after yield @@ -191,15 +191,15 @@ from protest import fixture, ProTestSession, ProTestSuite session = ProTestSession() suite = ProTestSuite("MyTests") -@fixture() +@fixture def expensive_resource(): return load_heavy_data() -@fixture() +@fixture def per_suite_db(): return create_db() -@fixture() # default: TEST scope (no binding needed) +@fixture # default: TEST scope (no binding needed) def per_test_data(): return fresh_data() @@ -278,7 +278,7 @@ def test_todo(): pass # Runtime skip condition with fixtures -@fixture() +@fixture def has_redis(): return shutil.which("redis-server") is not None @@ -364,7 +364,7 @@ async def test_async_operation(): assert result # Async fixtures work naturally -@fixture() +@fixture async def async_client(): async with aiohttp.ClientSession() as session: yield session @@ -475,7 +475,7 @@ protest run myapp.tests.session:session --lf ## Migration Checklist 1. **Create a session file** with `ProTestSession()` -2. **Convert fixtures to ProTest syntax** (`@fixture()`, `@factory()`) +2. **Convert fixtures to ProTest syntax** (`@fixture`, `@factory()`) 3. **Create suites** to group related tests 4. **Bind fixtures** to suites with appropriate scopes 5. **Convert test functions** with explicit `Annotated[T, Use(fixture)]` diff --git a/docs/guides/testing-fastapi.md b/docs/guides/testing-fastapi.md index fb96aae4..f9f9a9d2 100644 --- a/docs/guides/testing-fastapi.md +++ b/docs/guides/testing-fastapi.md @@ -69,7 +69,7 @@ from protest import fixture from myapp.app import app -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with LifespanManager(app) as manager: transport = httpx.ASGITransport(app=manager.app) @@ -144,7 +144,7 @@ from protest import fixture from myapp.app import app -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with LifespanManager(app) as manager: transport = httpx.ASGITransport(app=manager.app) @@ -239,7 +239,7 @@ class _LifespanWrapper: await self.app(scope, receive, send) -@fixture() +@fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with lifespan(app) as state: wrapped = _LifespanWrapper(app, state) diff --git a/docs/index.md b/docs/index.md index 98b68902..da540680 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,7 +37,7 @@ Tag a fixture once, and **every test using it inherits the tag automatically** def db(): ... session.bind(db) -@fixture() +@fixture def user_repo(db: Annotated[DB, Use(db)]): ... # Inherits "database" tag session.bind(user_repo) @@ -94,7 +94,7 @@ from protest import ProTestSession, Use, fixture session = ProTestSession() -@fixture() +@fixture def database(): return {"connected": True} diff --git a/docs/internals/dependency-injection.md b/docs/internals/dependency-injection.md index 4b5cb58d..7927c39f 100644 --- a/docs/internals/dependency-injection.md +++ b/docs/internals/dependency-injection.md @@ -30,11 +30,11 @@ ProTestSession ### Binding → Scope Mapping -| Binding | Scope | Internal `scope_path` | -|----------------------|---------|------------------------------------------| -| `session.bind(fn)` | Session | `None` | -| `suite.bind(fn)` | Suite | `suite.full_path` (e.g., `"API::Users"`) | -| No binding | Test | `""` | +| Binding | Scope | Internal `scope_path` | +|--------------------|---------|------------------------------------------| +| `session.bind(fn)` | Session | `None` | +| `suite.bind(fn)` | Suite | `suite.full_path` (e.g., `"API::Users"`) | +| No binding | Test | `""` | ### Nested Suite Paths @@ -62,12 +62,12 @@ A fixture can only depend on fixtures with **equal or wider** scope: Violating this raises `ScopeMismatchError`: ```python -@fixture() # Test scope (not bound) +@fixture # Test scope (not bound) def per_test(): return "fresh" -@fixture() +@fixture def shared(x: Annotated[str, Use(per_test)]): pass @@ -99,11 +99,11 @@ resolve(fixture_func) ### Three Cache Levels -| Scope | Where Cached | Lifetime | -|---------|-----------------------------------------|------------------| +| Scope | Where Cached | Lifetime | +|---------|-------------------------------------------------|------------------| | Session | `FixtureContainer._registry[func].cached_value` | Entire session | | Suite | `FixtureContainer._path_caches[path][func]` | While suite runs | -| Test | `TestExecutionContext._cache[func]` | Single test | +| Test | `TestExecutionContext._cache[func]` | Single test | Resolution is thread-safe. Concurrent tests requesting the same session fixture will wait for the first resolution to complete, then share the cached value. @@ -113,7 +113,7 @@ wait for the first resolution to complete, then share the cached value. Generator fixtures use `yield` to separate setup from teardown: ```python -@fixture() +@fixture async def database(): db = await connect() # Setup yield db # Value used by tests diff --git a/protest/di/decorators.py b/protest/di/decorators.py index 18b07772..47d6ffb2 100644 --- a/protest/di/decorators.py +++ b/protest/di/decorators.py @@ -7,7 +7,7 @@ import functools from collections.abc import Callable -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, overload from protest.di.validation import validate_no_from_params from protest.entities import FixtureMarker, FixtureRegistration @@ -43,10 +43,25 @@ def unwrap_fixture(func: Callable[..., Any]) -> Callable[..., Any]: return func +@overload +def fixture(_func: FuncT, /) -> FixtureWrapper[FuncT]: ... + + +@overload def fixture( + *, tags: list[str] | None = None, max_concurrency: int | None = None, -) -> Callable[[FuncT], FixtureWrapper[FuncT]]: +) -> Callable[[FuncT], FixtureWrapper[FuncT]]: ... + + +def fixture( + _func: FuncT | None = None, + /, + *, + tags: list[str] | None = None, + max_concurrency: int | None = None, +) -> FixtureWrapper[FuncT] | Callable[[FuncT], FixtureWrapper[FuncT]]: """Decorator to mark a function as a fixture. Scope is determined at binding time: @@ -103,6 +118,9 @@ def decorator(func: FuncT) -> FixtureWrapper[FuncT]: ) return FixtureWrapper(func, registration) + if _func is not None: + return decorator(_func) + return decorator diff --git a/protest/fixtures/builtins.py b/protest/fixtures/builtins.py index f649c146..63c61da2 100644 --- a/protest/fixtures/builtins.py +++ b/protest/fixtures/builtins.py @@ -8,7 +8,7 @@ from protest.fixtures.mocker import Mocker -@fixture() +@fixture def tmp_path() -> Generator[Path, None, None]: """Provide a temporary directory that is cleaned up after the test. @@ -23,14 +23,14 @@ def test_file_ops(tmp: Annotated[Path, Use(tmp_path)]): yield Path(tmpdir) -@fixture() +@fixture def caplog() -> LogCapture: """Capture log records during a test.""" records = get_current_log_records() return LogCapture(records) -@fixture() +@fixture def mocker() -> Generator[Mocker, None, None]: """Provide a mocker for patching and mocking during tests.""" mock_manager = Mocker() From fbdbb94458c6d319c767ad387d893c4b8783324b Mon Sep 17 00:00:00 2001 From: Julien Pillaud Date: Wed, 13 May 2026 11:58:41 +0200 Subject: [PATCH 2/3] fix: remove pycharm auto format --- docs/best-practices.md | 15 +++++++-------- docs/core-concepts/factories.md | 3 +-- docs/core-concepts/fixtures.md | 21 ++++++++++----------- docs/core-concepts/tags.md | 8 -------- docs/core-concepts/tests.md | 15 +++++++-------- docs/faq.md | 9 --------- docs/guides/pytest-to-protest.md | 15 ++++++++------- docs/internals/dependency-injection.md | 17 +++++++++-------- 8 files changed, 42 insertions(+), 61 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 66e1ca88..5a8f0258 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -91,8 +91,7 @@ import tests.test_users # noqa: F401, E402 import tests.test_orders # noqa: F401, E402 ``` -The imports are unused — they exist only to trigger `@suite.test()` registration at import time. This requires `# noqa` to silence linters and makes dependencies invisible. See -the [Project Organization guide](guides/project-organization.md) for the correct pattern. +The imports are unused — they exist only to trigger `@suite.test()` registration at import time. This requires `# noqa` to silence linters and makes dependencies invisible. See the [Project Organization guide](guides/project-organization.md) for the correct pattern. ## Suite Design @@ -161,11 +160,11 @@ di_suite = ProTestSuite( ### Scope Determines Placement -| Scope | Where to Define | Use Case | -|---------|-------------------------------|---------------------------------| -| Session | `session.py` or `fixtures.py` | Database, expensive resources | -| Suite | Suite file | Suite-specific setup | -| Test | Near test, `@fixture` | Test isolation, cheap resources | +| Scope | Where to Define | Use Case | +|-------|-----------------|----------| +| Session | `session.py` or `fixtures.py` | Database, expensive resources | +| Suite | Suite file | Suite-specific setup | +| Test | Near test, `@fixture` | Test isolation, cheap resources | ### Session Fixtures: Expensive Resources @@ -232,7 +231,6 @@ suite.bind(user) ``` Usage: - ```python @suite.test() async def test_user_permissions( @@ -511,3 +509,4 @@ async def test_draft_cannot_be_archived_directly( - [Factories](core-concepts/factories.md) - [Tags](core-concepts/tags.md) - Tag inheritance and filtering - [CLI Reference](cli.md) +- \ No newline at end of file diff --git a/docs/core-concepts/factories.md b/docs/core-concepts/factories.md index 983bde33..b75ca553 100644 --- a/docs/core-concepts/factories.md +++ b/docs/core-concepts/factories.md @@ -38,8 +38,7 @@ Key points: - Call the factory with `await` — it's always async - Each call can pass different arguments -> **Warning: Factory calls are always async**, even if the factory function itself is `def` (not `async def`). This means your test **must** be `async def` if it calls a factory. Internally, `FixtureFactory.__call__` is async because it -> uses `asyncio.Lock` for thread-safe caching. +> **Warning: Factory calls are always async**, even if the factory function itself is `def` (not `async def`). This means your test **must** be `async def` if it calls a factory. Internally, `FixtureFactory.__call__` is async because it uses `asyncio.Lock` for thread-safe caching. ### Common Mistake: Calling a Factory From a Sync Test diff --git a/docs/core-concepts/fixtures.md b/docs/core-concepts/fixtures.md index f3e0e2a2..4a9c919e 100644 --- a/docs/core-concepts/fixtures.md +++ b/docs/core-concepts/fixtures.md @@ -72,11 +72,11 @@ Use case: Isolated state per test, unique IDs, temporary files. ## Summary -| Binding | Scope | Caching | -|--------------------|---------|----------------------------------| -| `session.bind(fn)` | SESSION | One instance for entire session | -| `suite.bind(fn)` | SUITE | One instance per suite execution | -| No binding | TEST | Fresh instance per test | +| Binding | Scope | Caching | +|---------|-------|---------| +| `session.bind(fn)` | SESSION | One instance for entire session | +| `suite.bind(fn)` | SUITE | One instance per suite execution | +| No binding | TEST | Fresh instance per test | ## Teardown with yield @@ -102,11 +102,11 @@ Teardown runs in reverse order (LIFO). If multiple fixtures are used, the last o A fixture can only depend on fixtures with equal or wider scope: -| Fixture Scope | Can Depend On | -|---------------|------------------------------------| -| Session | Session only | -| Suite | Session, parent suites, same suite | -| Test | Anything | +| Fixture Scope | Can Depend On | +|---------------|---------------| +| Session | Session only | +| Suite | Session, parent suites, same suite | +| Test | Anything | Violating this raises `ScopeMismatchError`: @@ -144,7 +144,6 @@ This works transitively: if fixture A depends on fixture B with tag "x", tests u ## Limiting Concurrent Access with max_concurrency Some fixtures wrap resources that have limited concurrent access: - - Rate-limited APIs (e.g., max 2 requests/second) - Connection pools with fixed capacity - License-restricted resources diff --git a/docs/core-concepts/tags.md b/docs/core-concepts/tags.md index a66661a4..e90952ea 100644 --- a/docs/core-concepts/tags.md +++ b/docs/core-concepts/tags.md @@ -68,7 +68,6 @@ async def test_create_user(svc: Annotated[UserService, Use(user_service)]): ``` The dependency chain is: - ``` test_create_user → user_service → user_repository → db (tagged "database") ``` @@ -78,7 +77,6 @@ So `test_create_user` inherits the "database" tag without any explicit declarati ### Why This Matters **Without tag inheritance:** - ```python # You must manually tag EVERY test that touches the database @session.test(tags=["database"]) # Easy to forget! @@ -92,7 +90,6 @@ async def test_update_user(): ... ``` **With tag inheritance:** - ```python # Tag the fixture ONCE @fixture(tags=["database"]) @@ -178,7 +175,6 @@ protest tags list tests:session ``` Output: - ``` api database @@ -193,7 +189,6 @@ protest tags list tests:session -r ``` Output: - ``` test_create_user: api, database test_delete_user: api, database @@ -219,7 +214,6 @@ def storage(): ... ``` Run tests without external dependencies: - ```bash protest run tests:session --no-tag database --no-tag redis --no-tag s3 ``` @@ -235,7 +229,6 @@ async def test_full_migration(): ``` Quick feedback loop: - ```bash protest run tests:session --no-tag slow ``` @@ -253,7 +246,6 @@ async def test_deployment(): ... ``` Run locally (no Docker): - ```bash protest run tests:session --no-tag requires-docker ``` diff --git a/docs/core-concepts/tests.md b/docs/core-concepts/tests.md index fa642cee..7c5ce3e7 100644 --- a/docs/core-concepts/tests.md +++ b/docs/core-concepts/tests.md @@ -191,7 +191,6 @@ def test_local_only(env: Annotated[dict, Use(environment)]): ``` **How it works:** - 1. Fixtures are resolved for the test 2. ProTest introspects the skip callable's signature 3. Matching fixtures are passed as kwargs to the callable @@ -300,14 +299,14 @@ async def test_resilient(): When combining options: -| Combination | Behavior | -|--------------------------|-----------------------------------------------| -| `skip + xfail` | Skip takes priority (test not executed) | -| `skip + retry` | Skip takes priority | +| Combination | Behavior | +|-------------|----------| +| `skip + xfail` | Skip takes priority (test not executed) | +| `skip + retry` | Skip takes priority | | `skip(callable) + xfail` | Skip evaluated first; if skips, xfail ignored | -| `skip(callable) + retry` | Skip evaluated first; if skips, no retry | -| `xfail + retry` | Retry first, then xfail/xpass evaluation | -| `timeout + retry` | Timeout triggers retry | +| `skip(callable) + retry` | Skip evaluated first; if skips, no retry | +| `xfail + retry` | Retry first, then xfail/xpass evaluation | +| `timeout + retry` | Timeout triggers retry | ## Output Capture diff --git a/docs/faq.md b/docs/faq.md index 5951e56f..8c3f8cbd 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -33,7 +33,6 @@ user_suite = ProTestSuite("User") def user(): return User("alice") - user_suite.bind(user) # SUITE scope @@ -99,19 +98,13 @@ If fixtures could use `From()`, you'd get hidden cartesian products: @fixture def db(engine: Annotated[str, From(ENGINES)]): # 3 engines ... - - session.bind(db) - @fixture def user(role: Annotated[str, From(ROLES)], db: ...): # 2 roles ... - - session.bind(user) - @session.test() def test_perms(method: Annotated[str, From(METHODS)], user: ...): # 4 methods ... @@ -157,7 +150,6 @@ async def test_ffmpeg_conversion() -> None: ``` The `Shell` helper: - - Runs subprocesses with isolated pipes (no fd sharing issues) - Automatically prints output for ProTest to capture - Works safely with concurrent tests (`-n 4`) @@ -173,4 +165,3 @@ async def test_pipeline() -> None: ``` See `examples/subprocess_capture/session.py` for complete examples and [Built-ins](core-concepts/builtins.md#shell) for full API. - diff --git a/docs/guides/pytest-to-protest.md b/docs/guides/pytest-to-protest.md index 34d7b911..53c9577c 100644 --- a/docs/guides/pytest-to-protest.md +++ b/docs/guides/pytest-to-protest.md @@ -4,13 +4,13 @@ This guide helps you migrate existing pytest test suites to ProTest. It covers t ## Philosophy Shift -| pytest | ProTest | -|---------------------------------------------|------------------------------------------------------| -| Convention-based (discover `test_*.py`) | Explicit structure (Suites, Sessions) | -| Global fixtures via conftest.py | Explicit binding with `suite.bind()` | -| Implicit dependency injection | Explicit DI with `Annotated[T, Use(fixture)]` | -| Parametrize with `@pytest.mark.parametrize` | Parametrize with `ForEach` + `From` | -| Scope via `@pytest.fixture(scope=...)` | Scope via binding: `session.bind()` / `suite.bind()` | +| pytest | ProTest | +|--------|---------| +| Convention-based (discover `test_*.py`) | Explicit structure (Suites, Sessions) | +| Global fixtures via conftest.py | Explicit binding with `suite.bind()` | +| Implicit dependency injection | Explicit DI with `Annotated[T, Use(fixture)]` | +| Parametrize with `@pytest.mark.parametrize` | Parametrize with `ForEach` + `From` | +| Scope via `@pytest.fixture(scope=...)` | Scope via binding: `session.bind()` / `suite.bind()` | ## Quick Reference @@ -482,3 +482,4 @@ protest run myapp.tests.session:session --lf 6. **Replace `@pytest.mark.parametrize`** with `ForEach` + `From` 7. **Replace built-ins** (`tmp_path`, `caplog`, `mocker`) 8. **Update CI/CD** to use `protest run` command +9. \ No newline at end of file diff --git a/docs/internals/dependency-injection.md b/docs/internals/dependency-injection.md index 7927c39f..85b63969 100644 --- a/docs/internals/dependency-injection.md +++ b/docs/internals/dependency-injection.md @@ -30,11 +30,11 @@ ProTestSession ### Binding → Scope Mapping -| Binding | Scope | Internal `scope_path` | -|--------------------|---------|------------------------------------------| -| `session.bind(fn)` | Session | `None` | -| `suite.bind(fn)` | Suite | `suite.full_path` (e.g., `"API::Users"`) | -| No binding | Test | `""` | +| Binding | Scope | Internal `scope_path` | +|----------------------|---------|------------------------------------------| +| `session.bind(fn)` | Session | `None` | +| `suite.bind(fn)` | Suite | `suite.full_path` (e.g., `"API::Users"`) | +| No binding | Test | `""` | ### Nested Suite Paths @@ -99,11 +99,11 @@ resolve(fixture_func) ### Three Cache Levels -| Scope | Where Cached | Lifetime | -|---------|-------------------------------------------------|------------------| +| Scope | Where Cached | Lifetime | +|---------|-----------------------------------------|------------------| | Session | `FixtureContainer._registry[func].cached_value` | Entire session | | Suite | `FixtureContainer._path_caches[path][func]` | While suite runs | -| Test | `TestExecutionContext._cache[func]` | Single test | +| Test | `TestExecutionContext._cache[func]` | Single test | Resolution is thread-safe. Concurrent tests requesting the same session fixture will wait for the first resolution to complete, then share the cached value. @@ -240,3 +240,4 @@ class FixtureDurationPlugin(PluginBase): - [Factories](../core-concepts/factories.md) - Factory fixtures - [Events](events.md) - Complete event reference - [Plugins](plugins.md) - Plugin development guide +- \ No newline at end of file From d76ad3b93e7e4fb7ff58b3ba600b809454775947 Mon Sep 17 00:00:00 2001 From: Julien Pillaud Date: Wed, 13 May 2026 12:04:02 +0200 Subject: [PATCH 3/3] fix: remove pycharm auto format --- docs/best-practices.md | 3 +-- docs/faq.md | 2 +- docs/internals/dependency-injection.md | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 5a8f0258..578e6368 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -508,5 +508,4 @@ async def test_draft_cannot_be_archived_directly( - [Fixtures](core-concepts/fixtures.md) - [Factories](core-concepts/factories.md) - [Tags](core-concepts/tags.md) - Tag inheritance and filtering -- [CLI Reference](cli.md) -- \ No newline at end of file +- [CLI Reference](cli.md) \ No newline at end of file diff --git a/docs/faq.md b/docs/faq.md index 8c3f8cbd..6e7137b3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -164,4 +164,4 @@ async def test_pipeline() -> None: assert result.success ``` -See `examples/subprocess_capture/session.py` for complete examples and [Built-ins](core-concepts/builtins.md#shell) for full API. +See `examples/subprocess_capture/session.py` for complete examples and [Built-ins](core-concepts/builtins.md#shell) for full API. \ No newline at end of file diff --git a/docs/internals/dependency-injection.md b/docs/internals/dependency-injection.md index 85b63969..20849814 100644 --- a/docs/internals/dependency-injection.md +++ b/docs/internals/dependency-injection.md @@ -239,5 +239,4 @@ class FixtureDurationPlugin(PluginBase): - [Fixtures](../core-concepts/fixtures.md) - User guide for fixtures - [Factories](../core-concepts/factories.md) - Factory fixtures - [Events](events.md) - Complete event reference -- [Plugins](plugins.md) - Plugin development guide -- \ No newline at end of file +- [Plugins](plugins.md) - Plugin development guide \ No newline at end of file