pytest-plugin-utils 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_plugin_utils-0.1.0/PKG-INFO +89 -0
- pytest_plugin_utils-0.1.0/README.md +77 -0
- pytest_plugin_utils-0.1.0/pyproject.toml +46 -0
- pytest_plugin_utils-0.1.0/pytest_plugin_utils/__init__.py +10 -0
- pytest_plugin_utils-0.1.0/pytest_plugin_utils/artifacts.py +119 -0
- pytest_plugin_utils-0.1.0/pytest_plugin_utils/config.py +287 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-plugin-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable configuration and artifact utilities for building pytest plugins
|
|
5
|
+
Keywords: pytest,plugin,testing,utilities
|
|
6
|
+
Author: Michael Bianco
|
|
7
|
+
Author-email: Michael Bianco <mike@mikebian.co>
|
|
8
|
+
Requires-Dist: structlog-config>=0.10.0
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: Repository, https://github.com/iloveitaly/pytest-plugin-utils
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
[](https://github.com/iloveitaly/pytest-plugin-utils/releases)
|
|
14
|
+
[](https://pepy.tech/project/pytest-plugin-utils)
|
|
15
|
+

|
|
16
|
+
[](https://opensource.org/licenses/MIT)
|
|
17
|
+
|
|
18
|
+
# Reusable pytest Plugin Utilities
|
|
19
|
+
|
|
20
|
+
Building pytest plugins means dealing with the same problems repeatedly: managing configuration options with proper precedence (CLI vs INI vs defaults), creating per-test artifact directories, and sanitizing test names for filesystem paths. This package extracts those common patterns into reusable utilities.
|
|
21
|
+
|
|
22
|
+
I created this after extracting the config and path handling logic from `pytest-playwright-artifacts`. Rather than reinvent option handling in every plugin, you can use these utilities to get consistent behavior across pytest plugins.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add pytest-plugin-utils
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Configuration Options
|
|
33
|
+
|
|
34
|
+
Register pytest options with automatic precedence handling (runtime > CLI > INI > defaults) and type inference:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pytest_plugin_utils import set_pytest_option, register_pytest_options, get_pytest_option
|
|
38
|
+
|
|
39
|
+
def pytest_addoption(parser):
|
|
40
|
+
# Define your options (use __package__ for namespace)
|
|
41
|
+
set_pytest_option(
|
|
42
|
+
__package__,
|
|
43
|
+
"api_url",
|
|
44
|
+
default="http://localhost:3000",
|
|
45
|
+
help="API base URL",
|
|
46
|
+
available="all", # Expose via CLI and INI
|
|
47
|
+
type_hint=str,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Register them with pytest
|
|
51
|
+
register_pytest_options(__package__, parser)
|
|
52
|
+
|
|
53
|
+
def pytest_configure(config):
|
|
54
|
+
# Retrieve with automatic type casting
|
|
55
|
+
api_url = get_pytest_option(__package__, config, "api_url", type_hint=str)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Artifact Directory Management
|
|
59
|
+
|
|
60
|
+
Create per-test artifact directories with sanitized names:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from pytest_plugin_utils import set_artifact_dir_option, get_artifact_dir
|
|
64
|
+
|
|
65
|
+
def pytest_configure(config):
|
|
66
|
+
# Configure which option name to use (use __package__ for namespace)
|
|
67
|
+
set_artifact_dir_option(__package__, "my_plugin_output")
|
|
68
|
+
|
|
69
|
+
def pytest_runtest_setup(item):
|
|
70
|
+
# Get a clean directory for this specific test
|
|
71
|
+
artifact_dir = get_artifact_dir(__package__, item)
|
|
72
|
+
# Returns: /output/test-file-py-test-name-param/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Features
|
|
76
|
+
|
|
77
|
+
* Centralized option registry with runtime, CLI, and INI support
|
|
78
|
+
* Automatic INI type inference from Python type hints (bool, str, list[str], list[Path])
|
|
79
|
+
* Smart value casting with fallback precedence handling
|
|
80
|
+
* Filesystem-safe test name sanitization for artifact paths
|
|
81
|
+
* Per-test artifact directory creation and resolution
|
|
82
|
+
* Type-safe configuration retrieval with warnings on mismatches
|
|
83
|
+
|
|
84
|
+
## [MIT License](LICENSE.md)
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
*This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
|
|
89
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[](https://github.com/iloveitaly/pytest-plugin-utils/releases)
|
|
2
|
+
[](https://pepy.tech/project/pytest-plugin-utils)
|
|
3
|
+

|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
# Reusable pytest Plugin Utilities
|
|
7
|
+
|
|
8
|
+
Building pytest plugins means dealing with the same problems repeatedly: managing configuration options with proper precedence (CLI vs INI vs defaults), creating per-test artifact directories, and sanitizing test names for filesystem paths. This package extracts those common patterns into reusable utilities.
|
|
9
|
+
|
|
10
|
+
I created this after extracting the config and path handling logic from `pytest-playwright-artifacts`. Rather than reinvent option handling in every plugin, you can use these utilities to get consistent behavior across pytest plugins.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv add pytest-plugin-utils
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Configuration Options
|
|
21
|
+
|
|
22
|
+
Register pytest options with automatic precedence handling (runtime > CLI > INI > defaults) and type inference:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pytest_plugin_utils import set_pytest_option, register_pytest_options, get_pytest_option
|
|
26
|
+
|
|
27
|
+
def pytest_addoption(parser):
|
|
28
|
+
# Define your options (use __package__ for namespace)
|
|
29
|
+
set_pytest_option(
|
|
30
|
+
__package__,
|
|
31
|
+
"api_url",
|
|
32
|
+
default="http://localhost:3000",
|
|
33
|
+
help="API base URL",
|
|
34
|
+
available="all", # Expose via CLI and INI
|
|
35
|
+
type_hint=str,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Register them with pytest
|
|
39
|
+
register_pytest_options(__package__, parser)
|
|
40
|
+
|
|
41
|
+
def pytest_configure(config):
|
|
42
|
+
# Retrieve with automatic type casting
|
|
43
|
+
api_url = get_pytest_option(__package__, config, "api_url", type_hint=str)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Artifact Directory Management
|
|
47
|
+
|
|
48
|
+
Create per-test artifact directories with sanitized names:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pytest_plugin_utils import set_artifact_dir_option, get_artifact_dir
|
|
52
|
+
|
|
53
|
+
def pytest_configure(config):
|
|
54
|
+
# Configure which option name to use (use __package__ for namespace)
|
|
55
|
+
set_artifact_dir_option(__package__, "my_plugin_output")
|
|
56
|
+
|
|
57
|
+
def pytest_runtest_setup(item):
|
|
58
|
+
# Get a clean directory for this specific test
|
|
59
|
+
artifact_dir = get_artifact_dir(__package__, item)
|
|
60
|
+
# Returns: /output/test-file-py-test-name-param/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
* Centralized option registry with runtime, CLI, and INI support
|
|
66
|
+
* Automatic INI type inference from Python type hints (bool, str, list[str], list[Path])
|
|
67
|
+
* Smart value casting with fallback precedence handling
|
|
68
|
+
* Filesystem-safe test name sanitization for artifact paths
|
|
69
|
+
* Per-test artifact directory creation and resolution
|
|
70
|
+
* Type-safe configuration retrieval with warnings on mismatches
|
|
71
|
+
|
|
72
|
+
## [MIT License](LICENSE.md)
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
*This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
|
|
77
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-plugin-utils"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Reusable configuration and artifact utilities for building pytest plugins"
|
|
5
|
+
keywords = ["pytest", "plugin", "testing", "utilities"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["structlog-config>=0.10.0"]
|
|
9
|
+
authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
|
|
10
|
+
urls = { "Repository" = "https://github.com/iloveitaly/pytest-plugin-utils" }
|
|
11
|
+
|
|
12
|
+
# additional packaging information: https://packaging.python.org/en/latest/specifications/core-metadata/#license
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.10.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
# avoids the src/ directory structure
|
|
20
|
+
module-root = ""
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.3.4",
|
|
25
|
+
"pyright[nodejs]>=1.1.408",
|
|
26
|
+
"ruff>=0.15.0",
|
|
27
|
+
"coverage>=7.13.4",
|
|
28
|
+
"pytest-cov>=7.0.0",
|
|
29
|
+
"covdefaults>=2.3.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pyright]
|
|
33
|
+
exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
|
|
37
|
+
|
|
38
|
+
[tool.coverage.run]
|
|
39
|
+
plugins = ["covdefaults"]
|
|
40
|
+
source = ["pytest_plugin_utils"]
|
|
41
|
+
|
|
42
|
+
[tool.coverage.report]
|
|
43
|
+
fail_under = 50
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
extend-exclude = ["playground.py", "playground/"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pytest_plugin_utils.artifacts import (
|
|
2
|
+
get_artifact_dir as get_artifact_dir,
|
|
3
|
+
sanitize_for_artifacts as sanitize_for_artifacts,
|
|
4
|
+
set_artifact_dir_option as set_artifact_dir_option,
|
|
5
|
+
)
|
|
6
|
+
from pytest_plugin_utils.config import (
|
|
7
|
+
get_pytest_option as get_pytest_option,
|
|
8
|
+
register_pytest_options as register_pytest_options,
|
|
9
|
+
set_pytest_option as set_pytest_option,
|
|
10
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path handling utilities for pytest artifact management.
|
|
3
|
+
|
|
4
|
+
This module contains logic for determining where artifacts should be stored
|
|
5
|
+
for individual tests, including sanitization of test names and resolution
|
|
6
|
+
of output directories. The artifact directory option name can be customized
|
|
7
|
+
via set_artifact_dir_option().
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from .config import get_pytest_option
|
|
16
|
+
|
|
17
|
+
_artifact_dir_options: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_artifact_dir_option(namespace: str, option_name: str) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Set the pytest option name used for the artifact output directory.
|
|
23
|
+
|
|
24
|
+
This function should typically be called in pytest_configure() to customize
|
|
25
|
+
the option name before any tests run. It allows this module to be reused
|
|
26
|
+
by other pytest plugins that need different option names.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
# In your conftest.py or plugin module:
|
|
30
|
+
from pytest_plugin_utils.artifacts import set_artifact_dir_option
|
|
31
|
+
from pytest_plugin_utils.config import set_pytest_option
|
|
32
|
+
|
|
33
|
+
def pytest_configure(config):
|
|
34
|
+
# Register your custom option
|
|
35
|
+
set_pytest_option(
|
|
36
|
+
__package__,
|
|
37
|
+
"my_artifacts_output",
|
|
38
|
+
default="my-test-results",
|
|
39
|
+
help="Directory for test artifacts",
|
|
40
|
+
available="cli_option",
|
|
41
|
+
type_hint=str,
|
|
42
|
+
)
|
|
43
|
+
# Configure paths module to use it
|
|
44
|
+
set_artifact_dir_option(__package__, "my_artifacts_output")
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
48
|
+
option_name: The pytest option name (without '--' prefix, with underscores).
|
|
49
|
+
"""
|
|
50
|
+
_artifact_dir_options[namespace] = option_name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_artifact_dir_option(namespace: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Get the currently configured artifact directory option name.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The pytest option name used for the artifact output directory.
|
|
62
|
+
"""
|
|
63
|
+
assert namespace in _artifact_dir_options, (
|
|
64
|
+
f"call set_artifact_dir_option({namespace!r}, ...) before using get_artifact_dir_option()"
|
|
65
|
+
)
|
|
66
|
+
return _artifact_dir_options[namespace]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sanitize_for_artifacts(text: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Sanitize a test nodeid or name for use as a directory name.
|
|
72
|
+
|
|
73
|
+
This function replaces characters that are not alphanumeric or hyphens
|
|
74
|
+
with a single hyphen, and removes leading/trailing hyphens. This ensures
|
|
75
|
+
that the resulting string is safe to use as a directory name on most
|
|
76
|
+
file systems.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> sanitize_for_artifacts("test_file.py::test_func[param]")
|
|
80
|
+
'test-file-py-test-func-param'
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
text: The text to sanitize (e.g., a test nodeid).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A sanitized string safe for use as a directory name.
|
|
87
|
+
"""
|
|
88
|
+
sanitized = re.sub(r"[^A-Za-z0-9]+", "-", text)
|
|
89
|
+
sanitized = re.sub(r"-+", "-", sanitized).strip("-")
|
|
90
|
+
return sanitized or "unknown-test"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_artifact_dir(namespace: str, item: pytest.Item) -> Path:
|
|
94
|
+
"""
|
|
95
|
+
Get or create the artifact directory for a specific test item.
|
|
96
|
+
|
|
97
|
+
This function determines the root output directory based on the configured
|
|
98
|
+
artifact directory option (see set_artifact_dir_option). It then creates
|
|
99
|
+
a subdirectory for the specific test item using its sanitized nodeid.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
103
|
+
item: The pytest.Item (test case) for which to get the directory.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A pathlib.Path object pointing to the specific test's artifact directory.
|
|
107
|
+
The directory and its parents are created if they do not exist.
|
|
108
|
+
"""
|
|
109
|
+
assert namespace in _artifact_dir_options, (
|
|
110
|
+
f"call set_artifact_dir_option({namespace!r}, ...) before using get_artifact_dir()"
|
|
111
|
+
)
|
|
112
|
+
option_name = _artifact_dir_options[namespace]
|
|
113
|
+
output_path = get_pytest_option(namespace, item.config, option_name, type_hint=Path)
|
|
114
|
+
assert output_path
|
|
115
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
per_test_dir = output_path / sanitize_for_artifacts(item.nodeid)
|
|
118
|
+
per_test_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
return per_test_dir
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest option registry and resolution helpers for this plugin.
|
|
3
|
+
|
|
4
|
+
Options are registered once, then resolved at read time with a consistent
|
|
5
|
+
precedence: runtime overrides > INI > defaults from the registry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typing as t
|
|
9
|
+
import warnings
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from _pytest.config import Config
|
|
15
|
+
from _pytest.config.argparsing import Parser
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger(logger_name=__package__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class OptionDef:
|
|
22
|
+
"""
|
|
23
|
+
Internal representation of the options this plugin wants to expose to pytest.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
default: t.Any
|
|
28
|
+
help_text: str
|
|
29
|
+
available: t.Literal["all", "ini", "cli_option", None]
|
|
30
|
+
type_hint: t.Any | None
|
|
31
|
+
ini_type: (
|
|
32
|
+
t.Literal[
|
|
33
|
+
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
|
|
34
|
+
]
|
|
35
|
+
| None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
REGISTRY: dict[str, list[OptionDef]] = {}
|
|
40
|
+
"configuration options this plugin wants to expose to pytest, keyed by namespace"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _infer_ini_type(
|
|
44
|
+
type_hint: t.Any,
|
|
45
|
+
) -> (
|
|
46
|
+
t.Literal["string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"]
|
|
47
|
+
| None
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Infer the pytest INI type string from a Python type hint.
|
|
51
|
+
|
|
52
|
+
Supported mappings:
|
|
53
|
+
- bool -> "bool"
|
|
54
|
+
- str -> "string"
|
|
55
|
+
- list[str] -> "linelist"
|
|
56
|
+
- list[Path] -> "paths"
|
|
57
|
+
|
|
58
|
+
Unsupported/Not inferred:
|
|
59
|
+
- "args" (list of whitespace-separated strings)
|
|
60
|
+
- "pathlist" (legacy alias for "paths")
|
|
61
|
+
"""
|
|
62
|
+
if type_hint is bool:
|
|
63
|
+
return "bool"
|
|
64
|
+
if type_hint is str:
|
|
65
|
+
return "string"
|
|
66
|
+
|
|
67
|
+
origin = t.get_origin(type_hint)
|
|
68
|
+
args = t.get_args(type_hint)
|
|
69
|
+
|
|
70
|
+
if origin is list:
|
|
71
|
+
if args and args[0] is str:
|
|
72
|
+
return "linelist"
|
|
73
|
+
if args and issubclass(args[0], Path):
|
|
74
|
+
return "paths"
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_pytest_option(
|
|
80
|
+
namespace: str,
|
|
81
|
+
name: str,
|
|
82
|
+
*,
|
|
83
|
+
default: t.Any = None,
|
|
84
|
+
help: str = "",
|
|
85
|
+
available: t.Literal["all", "ini", "cli_option", None] = None,
|
|
86
|
+
type_hint: t.Any | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Define a pytest option.
|
|
90
|
+
|
|
91
|
+
This queues the option for registration (hook_addoption) and
|
|
92
|
+
configuration (hook_configure).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
96
|
+
name: The key name (e.g. "api_url"). Use underscores.
|
|
97
|
+
default: The fallback value if not provided via CLI or INI.
|
|
98
|
+
help: Help text for the CLI/INI description.
|
|
99
|
+
available: Where this option should be exposed to the user.
|
|
100
|
+
- 'cli_option': Adds a --flag.
|
|
101
|
+
- 'ini': Adds a value to pytest.ini.
|
|
102
|
+
- 'all': Adds both.
|
|
103
|
+
- None: Purely internal/runtime (set via code only).
|
|
104
|
+
type_hint: Optional Python type hint (e.g. bool, list[str]) used for
|
|
105
|
+
validation and INI type inference.
|
|
106
|
+
"""
|
|
107
|
+
ini_type = _infer_ini_type(type_hint)
|
|
108
|
+
if namespace not in REGISTRY:
|
|
109
|
+
REGISTRY[namespace] = []
|
|
110
|
+
REGISTRY[namespace].append(
|
|
111
|
+
OptionDef(
|
|
112
|
+
name=name,
|
|
113
|
+
default=default,
|
|
114
|
+
help_text=help,
|
|
115
|
+
available=available,
|
|
116
|
+
type_hint=type_hint,
|
|
117
|
+
ini_type=ini_type,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def register_pytest_options(namespace: str, parser: Parser) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Must be called within `pytest_addoption` to register CLI/INI flags.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
128
|
+
parser: The pytest parser to register options with.
|
|
129
|
+
"""
|
|
130
|
+
for opt in REGISTRY.get(namespace, []):
|
|
131
|
+
help_text = opt.help_text
|
|
132
|
+
if opt.default is not None:
|
|
133
|
+
help_text = f"{opt.help_text} (default: {opt.default})"
|
|
134
|
+
|
|
135
|
+
# CLI Registration
|
|
136
|
+
if opt.available in ("all", "cli_option"):
|
|
137
|
+
cli_name = f"--{opt.name.replace('_', '-')}"
|
|
138
|
+
# CRITICAL: We set default=None here so CLI allows fallback to INI/Runtime
|
|
139
|
+
parser.addoption(cli_name, action="store", default=None, help=help_text)
|
|
140
|
+
|
|
141
|
+
# INI Registration
|
|
142
|
+
if opt.available in ("all", "ini"):
|
|
143
|
+
# We set default=None here so INI allows fallback to Runtime default
|
|
144
|
+
parser.addini(opt.name, help=help_text, default=None, type=opt.ini_type)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _smart_cast[T](value: t.Any, type_hint: type[T] | None) -> T | t.Any:
|
|
148
|
+
"""
|
|
149
|
+
Cast a value to the expected type if it's not already correct.
|
|
150
|
+
This handles cases where CLI arguments (always strings) need conversion,
|
|
151
|
+
or where default values might not match the strict type.
|
|
152
|
+
"""
|
|
153
|
+
log.debug("casting value", raw_value=value, target_type=type_hint)
|
|
154
|
+
|
|
155
|
+
if type_hint is None:
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
# Handle GenericAlias types (e.g. list[str]) for isinstance checks
|
|
159
|
+
origin = t.get_origin(type_hint)
|
|
160
|
+
check_type = origin if origin is not None else type_hint
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
if isinstance(value, check_type):
|
|
164
|
+
log.debug("value already correct type, no conversion needed")
|
|
165
|
+
return value
|
|
166
|
+
except TypeError:
|
|
167
|
+
# Fallback if isinstance fails (e.g. some complex types)
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
if value is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Casting logic for strings (from CLI or raw defaults)
|
|
174
|
+
if type_hint is bool and isinstance(value, str):
|
|
175
|
+
result = value.lower() in ("true", "1", "yes", "on")
|
|
176
|
+
log.debug("converted string to bool", converted_value=result)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
if origin is list and isinstance(value, str):
|
|
180
|
+
# list("foo") produces ['f', 'o', 'o'], so handle string-to-list specially
|
|
181
|
+
# by splitting on newlines (CLI args or raw strings from config)
|
|
182
|
+
result = [v.strip() for v in value.splitlines() if v.strip()]
|
|
183
|
+
log.debug("converted string to list", converted_value=result)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
# Generic fallback: call type_hint(value) as constructor
|
|
187
|
+
try:
|
|
188
|
+
if origin is not None:
|
|
189
|
+
result = t.cast(type, origin)(value)
|
|
190
|
+
else:
|
|
191
|
+
result = t.cast(type, type_hint)(value)
|
|
192
|
+
log.debug("converted using type constructor", converted_value=result)
|
|
193
|
+
return result
|
|
194
|
+
except (TypeError, ValueError) as e:
|
|
195
|
+
log.debug("failed to convert value", error=str(e))
|
|
196
|
+
raise TypeError(
|
|
197
|
+
f"Cannot cast value of type {type(value)} to {type_hint}"
|
|
198
|
+
) from e
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_pytest_option[T](
|
|
202
|
+
namespace: str, config: Config, key: str, *, type_hint: type[T] | None = None
|
|
203
|
+
) -> T | t.Any | None:
|
|
204
|
+
"""
|
|
205
|
+
Retrieve a configuration value from runtime overrides, CLI, or INI files.
|
|
206
|
+
|
|
207
|
+
Priority chain:
|
|
208
|
+
1. Runtime overrides (via config.option in pytest_configure)
|
|
209
|
+
2. CLI arguments (e.g., --my-key)
|
|
210
|
+
3. Configuration files (pytest.ini, pyproject.toml)
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
namespace: Unique namespace for this plugin (typically __package__).
|
|
214
|
+
config: The pytest Config object.
|
|
215
|
+
key: The option name (use underscores).
|
|
216
|
+
type_hint: Optional expected type for validation and smart casting.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The resolved value, optionally casted. Returns None if not found.
|
|
220
|
+
"""
|
|
221
|
+
log.debug(
|
|
222
|
+
"getting pytest option", namespace=namespace, key=key, type_hint=type_hint
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
normalized_key = key.replace("-", "_")
|
|
226
|
+
opt = next(
|
|
227
|
+
(
|
|
228
|
+
entry
|
|
229
|
+
for entry in REGISTRY.get(namespace, [])
|
|
230
|
+
if entry.name == normalized_key
|
|
231
|
+
),
|
|
232
|
+
None,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Validation
|
|
236
|
+
if type_hint is not None and opt is not None and opt.type_hint is not None:
|
|
237
|
+
if type_hint != opt.type_hint:
|
|
238
|
+
warnings.warn(
|
|
239
|
+
f"Type mismatch for option '{key}': requested {type_hint}, configured {opt.type_hint}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# CLI/runtime value from config.option (argparse Namespace)
|
|
243
|
+
val = getattr(config.option, normalized_key, None)
|
|
244
|
+
source = None
|
|
245
|
+
|
|
246
|
+
if val in (None, ""):
|
|
247
|
+
# INI value from pytest.ini or pyproject.toml
|
|
248
|
+
try:
|
|
249
|
+
val = config.getini(normalized_key)
|
|
250
|
+
if val not in (None, ""):
|
|
251
|
+
source = "ini"
|
|
252
|
+
except (ValueError, KeyError):
|
|
253
|
+
val = None
|
|
254
|
+
|
|
255
|
+
else:
|
|
256
|
+
source = "cli"
|
|
257
|
+
|
|
258
|
+
if val in (None, ""):
|
|
259
|
+
# Default value from the registry
|
|
260
|
+
if opt is not None:
|
|
261
|
+
val = opt.default
|
|
262
|
+
source = "default"
|
|
263
|
+
|
|
264
|
+
log.debug("resolved raw value", key=key, raw_value=val, source=source)
|
|
265
|
+
|
|
266
|
+
# Determine effective type hint
|
|
267
|
+
effective_type_hint = type_hint
|
|
268
|
+
if effective_type_hint is None and opt is not None:
|
|
269
|
+
effective_type_hint = opt.type_hint
|
|
270
|
+
|
|
271
|
+
# Smart cast
|
|
272
|
+
if val is not None and effective_type_hint is not None:
|
|
273
|
+
try:
|
|
274
|
+
result = _smart_cast(val, effective_type_hint)
|
|
275
|
+
log.debug("returning converted value", key=key, converted_value=result)
|
|
276
|
+
return result
|
|
277
|
+
except TypeError as e:
|
|
278
|
+
# warning? or just return val?
|
|
279
|
+
# Let's log a warning and return val to be safe
|
|
280
|
+
warnings.warn(f"Failed to cast option '{key}': {e}")
|
|
281
|
+
log.debug(
|
|
282
|
+
"returning raw value after conversion failure", key=key, value=val
|
|
283
|
+
)
|
|
284
|
+
return val
|
|
285
|
+
|
|
286
|
+
log.debug("returning raw value", key=key, value=val)
|
|
287
|
+
return val
|