Skip to content

Commit 0bb2d5a

Browse files
Python(feat): pytest plugin improvements (#567)
1 parent 37e5e2e commit 0bb2d5a

14 files changed

Lines changed: 1264 additions & 293 deletions

File tree

.githooks/pre-push-python/extras.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# ensure generated pyproject.toml extras are up-to-date
22

3+
# Clear git env vars set by the parent hook so git commands resolve the work tree normally
4+
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX
5+
36
# Store the root directory of the repository
47
REPO_ROOT="$(git rev-parse --show-toplevel)"
58
PYTHON_DIR="$REPO_ROOT/python"

.githooks/pre-push-python/fmt-lint.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
set -e
44

5+
# Clear git env vars set by the parent hook so git commands resolve the work tree normally
6+
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX
7+
58
# Store the root directory of the repository
69
REPO_ROOT="$(git rev-parse --show-toplevel)"
710
PYTHON_DIR="$REPO_ROOT/python"

.githooks/pre-push-python/stubs.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# ensure generated python stubs are up-to-date, from sync clients
22

3+
# Clear git env vars set by the parent hook so git commands resolve the work tree normally
4+
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX
5+
36
# Store the root directory of the repository
47
REPO_ROOT="$(git rev-parse --show-toplevel)"
58
PYTHON_DIR="$REPO_ROOT/python"

python/docs/examples/pytest_plugin.md

Lines changed: 147 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ This page walks through wiring the plugin into a project, the fixtures and
99
hooks it provides, and the patterns you'll use day-to-day.
1010

1111
!!! info "Where the plugin lives"
12-
The plugin is part of `sift_client.util.test_results`. It is **not**
13-
registered as a `pytest11` entry point. Projects opt in with a
14-
`from sift_client.util.test_results import *` in their `conftest.py`.
15-
That import is what wires up the fixtures, the CLI options, and the
16-
`pytest_runtest_makereport` hook.
12+
The plugin lives at `sift_client.pytest_plugin`. It is
13+
**not** registered as a `pytest11` entry point. Projects opt in with a
14+
`pytest_plugins` declaration in their top-level `conftest.py`. Pytest
15+
then loads the module as a real plugin: the fixtures, CLI options, and
16+
`pytest_runtest_makereport` hook all register through standard pytest
17+
machinery, so `pytest --trace-config` lists it and
18+
`pytest -p no:sift_client.pytest_plugin` disables it.
1719

1820
## Install
1921

@@ -33,9 +35,26 @@ The `SIFT_GRPC_URI` and `SIFT_REST_URI` are the gRPC and REST endpoints for your
3335

3436
## Wire the plugin into `conftest.py`
3537

36-
Two things are required: a session-scoped `sift_client` fixture (the plugin's
37-
`report_context` fixture resolves it by name), and a star-import that registers
38-
the plugin's fixtures into the conftest's namespace.
38+
A single `pytest_plugins` declaration in your top-level `conftest.py` is all
39+
that's required. The plugin ships a default `sift_client` fixture that reads
40+
`SIFT_API_KEY`, `SIFT_GRPC_URI`, and `SIFT_REST_URI` from the environment.
41+
42+
```python title="conftest.py"
43+
from dotenv import load_dotenv
44+
45+
load_dotenv()
46+
47+
pytest_plugins = ["sift_client.pytest_plugin"]
48+
```
49+
50+
That's the whole setup. Every test in the session will now create a step on a
51+
single shared `TestReport`.
52+
53+
### Customizing the `SiftClient`
54+
55+
To construct the client differently (custom TLS, timeouts, alternate
56+
credentials, etc.), override the `sift_client` fixture in your conftest. The
57+
plugin's default falls away in favor of your definition.
3958

4059
```python title="conftest.py"
4160
import os
@@ -45,30 +64,23 @@ from dotenv import load_dotenv
4564

4665
from sift_client import SiftClient, SiftConnectionConfig
4766

48-
# Star-import wires fixtures + hooks + CLI options into pytest collection.
49-
from sift_client.util.test_results import *
50-
5167
load_dotenv()
5268

69+
pytest_plugins = ["sift_client.pytest_plugin"]
70+
5371

5472
@pytest.fixture(scope="session")
5573
def sift_client() -> SiftClient:
56-
grpc_url = os.getenv("SIFT_GRPC_URI")
57-
rest_url = os.getenv("SIFT_REST_URI")
58-
api_key = os.getenv("SIFT_API_KEY")
59-
6074
return SiftClient(
6175
connection_config=SiftConnectionConfig(
62-
api_key=api_key,
63-
grpc_url=grpc_url,
64-
rest_url=rest_url,
76+
api_key=os.getenv("SIFT_API_KEY"),
77+
grpc_url=os.getenv("SIFT_GRPC_URI"),
78+
rest_url=os.getenv("SIFT_REST_URI"),
79+
use_ssl=False,
6580
)
6681
)
6782
```
6883

69-
That's the whole setup. Every test in the session will now create a step on a
70-
single shared `TestReport`.
71-
7284
## Plugin provided fixtures
7385

7486
| Name | Kind | Scope | Purpose |
@@ -86,17 +98,82 @@ single shared `TestReport`.
8698
| `--no-sift-test-results-git-metadata` | git metadata on | Skip capturing git repo/branch/commit on the report's metadata. |
8799
| `--sift-test-results-check-connection` | off | Make `report_context`, `step`, and `module_substep` no-op (yield `None`) when `client_has_connection` is `False`. Lets the same suite run locally without a Sift backend. |
88100

89-
These can be set permanently in `pytest.ini`:
101+
These can be passed permanently via `addopts`:
90102

91103
```ini title="pytest.ini"
92104
[pytest]
93105
addopts = --sift-test-results-check-connection
94106
```
95107

108+
Or set the matching ini key directly (recommended for stable per-project
109+
configuration). Each CLI flag has a corresponding key under
110+
`[tool.pytest.ini_options]` in `pyproject.toml` or `[pytest]` in `pytest.ini`.
111+
CLI flags, when passed, override the ini values.
112+
113+
| Ini key | Type | Equivalent CLI flag |
114+
|---|---|---|
115+
| `sift_test_results_log_file` | string (`true` / `false` / `none` / path) | `--sift-test-results-log-file=<value>` |
116+
| `sift_test_results_git_metadata` | bool (default `true`) | `--no-sift-test-results-git-metadata` (sets to `false`) |
117+
| `sift_test_results_check_connection` | bool (default `false`) | `--sift-test-results-check-connection` |
118+
| `sift_test_results_autouse` | bool (default `true`) | _(no CLI flag; controls the marker gate below)_ |
119+
120+
The default `sift_client` fixture reads its two URIs from environment first
121+
and falls back to ini keys when the env vars are unset. `SIFT_API_KEY` is
122+
intentionally env-only — keep it out of source control and supply it through
123+
`pytest-dotenv` (see [API key handling](#api-key-handling) below). The env
124+
var wins when both are set, so secrets injected into a CI environment
125+
continue to override values committed to `pyproject.toml`. There are no CLI
126+
flags for credentials.
127+
128+
| Ini key | Environment variable | Notes |
129+
|---|---|---|
130+
| _(none)_ | `SIFT_API_KEY` | Env-only. Use `.env` + `pytest-dotenv` locally; inject from your secret store in CI. |
131+
| `sift_grpc_uri` | `SIFT_GRPC_URI` | Stable per-org gRPC endpoint; safe to commit. |
132+
| `sift_rest_uri` | `SIFT_REST_URI` | Stable per-org REST endpoint; safe to commit. |
133+
134+
```toml title="pyproject.toml"
135+
[tool.pytest.ini_options]
136+
sift_test_results_check_connection = true
137+
sift_test_results_log_file = "false"
138+
sift_test_results_git_metadata = false
139+
sift_grpc_uri = "your-org.sift.example:443"
140+
sift_rest_uri = "https://your-org.sift.example"
141+
```
142+
143+
```ini title="pytest.ini"
144+
[pytest]
145+
sift_test_results_check_connection = true
146+
sift_test_results_log_file = false
147+
sift_test_results_git_metadata = false
148+
sift_grpc_uri = your-org.sift.example:443
149+
sift_rest_uri = https://your-org.sift.example
150+
```
151+
152+
#### API key handling
153+
154+
`SIFT_API_KEY` is deliberately read from the process environment only. The
155+
recommended workflow uses the
156+
[`pytest-dotenv`](https://pypi.org/project/pytest-dotenv/) plugin (already a
157+
dependency of `sift-stack-py`), which loads variables from a `.env` file
158+
into `os.environ` before tests run.
159+
160+
1. Add `.env` to `.gitignore`.
161+
2. Drop your key into `.env` at the project root:
162+
163+
```bash title=".env"
164+
SIFT_API_KEY=sk-...your-key...
165+
```
166+
167+
3. In CI, set `SIFT_API_KEY` directly via your provider's secret manager
168+
instead of committing a `.env` file.
169+
170+
`pytest-dotenv` picks the file up automatically; no `pytest_configure`
171+
glue is needed.
172+
96173
!!! warning "FedRAMP / shared environments"
97-
Pass `--sift-test-results-log-file=false` to skip the temp file + worker
98-
pipeline. Create/update calls then run inline against the API instead of
99-
being deferred through a subprocess.
174+
Pass `--sift-test-results-log-file=false` (or set the ini key to `"false"`)
175+
to skip the temp file + worker pipeline. Create/update calls then run
176+
inline against the API instead of being deferred through a subprocess.
100177
101178
### Report metadata captured automatically
102179
@@ -122,6 +199,50 @@ metadata), call `report_context.report.update({...})` from any test or
122199
fixture. See [Linking a Run](#linking-a-run-to-the-report) for the same
123200
pattern applied to `run_id`.
124201
202+
## Controlling which tests produce reports
203+
204+
By default every test in the session produces a Sift step. Two markers
205+
and one ini key let you narrow that to a specific set of tests, which is
206+
useful when a repo holds tests that you don't want included in the Sift test report.
207+
208+
| Setting | Effect |
209+
|---------------------------------------------------------|----------------------------------------------------------------------------------------------|
210+
| `sift_test_results_autouse = false` in `pyproject.toml` | Flip the project-wide default off. Tests no longer produce steps unless explicitly opted in. |
211+
| `@pytest.mark.sift_include` on a test, class, or module | Force reporting on for that scope, regardless of the project default. |
212+
| `@pytest.mark.sift_exclude` on a test, class, or module | Force reporting off for that scope, regardless of the project default. |
213+
214+
Closest marker determines setting. `sift_exclude` beats `sift_include` when both apply.
215+
`pytestmark` at the class or module level inherits to every test in scope.
216+
217+
### Bulk-applying a marker to a directory
218+
219+
To opt an entire directory in (or out) without editing each file, hook
220+
`pytest_collection_modifyitems` in the directory's `conftest.py`:
221+
222+
```python title="tests/example/conftest.py"
223+
from pathlib import Path
224+
225+
import pytest
226+
227+
_HERE = Path(__file__).parent
228+
229+
230+
def pytest_collection_modifyitems(config, items):
231+
for item in items:
232+
try:
233+
item.path.relative_to(_HERE)
234+
except ValueError:
235+
continue
236+
item.add_marker(pytest.mark.sift_include)
237+
```
238+
239+
This applies `sift_include` to every test collected under `tests/example/`.
240+
Combine with `sift_test_results_autouse = false` in `pyproject.toml` for
241+
opting in to specific directories.
242+
243+
`pytest_collection_modifyitems` receives every item in the session, not just
244+
this directory's, so the `relative_to` filter is what scopes the marker.
245+
125246
## Basic usage
126247

127248
With the conftest in place, the simplest test needs nothing extra. The `step`
@@ -585,7 +706,7 @@ automatic skip.
585706
```python title="conftest.py"
586707
import pytest
587708
588-
from sift_client.util.test_results import *
709+
pytest_plugins = ["sift_client.pytest_plugin"]
589710
590711
591712
@pytest.fixture(autouse=True)

python/lib/sift_client/_tests/conftest.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,6 @@ def ci_pytest_tag(sift_client):
7878
return tag
7979

8080

81-
# Import the Sift test results fixtures the way we recommend to users.
82-
from sift_client.util.test_results import * # noqa: F403
83-
84-
8581
def pytest_configure(config: pytest.Config) -> None:
8682
"""Enable the Sift connection-check mode for the fixtures used in this test suite since we run w/ mock client in non-integration tests."""
8783
config.option.sift_test_results_check_connection = True

python/lib/sift_client/_tests/pytest_plugin/__init__.py

Whitespace-only changes.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Shared helpers for the pytest-plugin test suite.
2+
3+
The tests in this directory drive inner pytester sessions to exercise the
4+
plugin's behavior in isolation. The fixtures below produce the boilerplate
5+
conftests those inner sessions need:
6+
7+
- ``write_plugin_conftest``: minimal conftest that loads the plugin
8+
- ``write_probe_conftest``: conftest that loads the plugin and runs a probe
9+
block inside ``pytest_configure``, useful for inspecting internal state
10+
without running tests against a real backend
11+
12+
Every test in this suite invokes the inner session via
13+
``pytester.runpytest_subprocess(...)`` rather than ``pytester.runpytest(...)``.
14+
``runpytest`` runs the inner pytest in-process, which re-imports the Sift
15+
plugin on each test; the plugin transitively imports numpy, whose C
16+
extensions refuse to initialize twice in one process and raise
17+
``cannot load module more than once per process``. Spawning a subprocess
18+
gives each inner session a fresh interpreter and sidesteps that guard.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import textwrap
24+
from typing import Callable
25+
26+
import pytest
27+
28+
29+
@pytest.fixture
30+
def write_plugin_conftest(pytester: pytest.Pytester) -> Callable[[], None]:
31+
"""Return a callable that writes a minimal conftest loading the plugin."""
32+
33+
def _write() -> None:
34+
pytester.makeconftest('pytest_plugins = ["sift_client.pytest_plugin"]')
35+
36+
return _write
37+
38+
39+
@pytest.fixture
40+
def write_probe_conftest(pytester: pytest.Pytester) -> Callable[[str], None]:
41+
"""Return a callable that writes a conftest running ``probe_body`` in ``pytest_configure``.
42+
43+
``probe_body`` is python source that runs at config time with ``config``
44+
in scope; use ``print(...)`` calls and capture them with
45+
``result.stdout.fnmatch_lines``.
46+
"""
47+
48+
def _write(probe_body: str) -> None:
49+
pytester.makeconftest(
50+
'pytest_plugins = ["sift_client.pytest_plugin"]\n\n'
51+
"def pytest_configure(config):\n" + textwrap.indent(textwrap.dedent(probe_body), " ")
52+
)
53+
54+
return _write

0 commit comments

Comments
 (0)