pytest-optional-dependencies 0.1.2__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_optional_dependencies-0.1.2/.github/.deployment.md +22 -0
- pytest_optional_dependencies-0.1.2/.github/FUNDING.yml +3 -0
- pytest_optional_dependencies-0.1.2/.github/workflows/main.yml +46 -0
- pytest_optional_dependencies-0.1.2/.github/workflows/publish-to-pypi.yml +23 -0
- pytest_optional_dependencies-0.1.2/.gitignore +15 -0
- pytest_optional_dependencies-0.1.2/CHANGELOG.md +14 -0
- pytest_optional_dependencies-0.1.2/LICENSE +21 -0
- pytest_optional_dependencies-0.1.2/PKG-INFO +113 -0
- pytest_optional_dependencies-0.1.2/README.md +89 -0
- pytest_optional_dependencies-0.1.2/examples/README.md +27 -0
- pytest_optional_dependencies-0.1.2/examples/pyproject.toml +4 -0
- pytest_optional_dependencies-0.1.2/examples/test_bad_dependency.py +5 -0
- pytest_optional_dependencies-0.1.2/examples/test_optional_dependency.py +5 -0
- pytest_optional_dependencies-0.1.2/examples/test_simple.py +2 -0
- pytest_optional_dependencies-0.1.2/pyproject.toml +51 -0
- pytest_optional_dependencies-0.1.2/src/pytest_optional_dependencies/__init__.py +4 -0
- pytest_optional_dependencies-0.1.2/src/pytest_optional_dependencies/plugin.py +288 -0
- pytest_optional_dependencies-0.1.2/tests/conftest.py +1 -0
- pytest_optional_dependencies-0.1.2/tests/test_helpers.py +98 -0
- pytest_optional_dependencies-0.1.2/tests/test_missing_imports.py +272 -0
- pytest_optional_dependencies-0.1.2/tox.ini +46 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Build & Deploy
|
|
2
|
+
|
|
3
|
+
Note: If you're not Brian, don't try this.
|
|
4
|
+
|
|
5
|
+
## Modify version
|
|
6
|
+
|
|
7
|
+
Change the version in pyproject.toml
|
|
8
|
+
|
|
9
|
+
## Update Changelog
|
|
10
|
+
|
|
11
|
+
Update changelog.md
|
|
12
|
+
|
|
13
|
+
## Tag
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
(ok) $ git tag -a 2.4.1 -m 'some message'
|
|
17
|
+
(ok) $ git push --tags
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Release
|
|
21
|
+
|
|
22
|
+
Go to [new release](https://github.com/okken/pytest-optional-dependencies/releases/new) and manually create one based on the above tag.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Python package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
env:
|
|
10
|
+
FORCE_COLOR: "1"
|
|
11
|
+
TOX_TESTENV_PASSENV: FORCE_COLOR
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v5
|
|
24
|
+
- name: Setup Python
|
|
25
|
+
uses: actions/setup-python@v6
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
# allow-prereleases: true # needed for 3.14
|
|
29
|
+
- name: Install Tox and any other packages
|
|
30
|
+
run: pip install tox tox-uv
|
|
31
|
+
- name: Run Tox
|
|
32
|
+
run: tox -e py
|
|
33
|
+
|
|
34
|
+
static-analysis:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v5
|
|
39
|
+
- name: Setup Python
|
|
40
|
+
uses: actions/setup-python@v6
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.14"
|
|
43
|
+
- name: Install Tox and any other packages
|
|
44
|
+
run: pip install tox tox-uv
|
|
45
|
+
- name: Run Tox quality checks
|
|
46
|
+
run: tox -e lint,format
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on: push
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
build-n-publish:
|
|
7
|
+
name: Build and publish to PyPI and TestPyPI
|
|
8
|
+
if: startsWith(github.ref, 'refs/tags')
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v5
|
|
14
|
+
- name: Set up Python 3.12
|
|
15
|
+
uses: actions/setup-python@v6
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Install pypa/build
|
|
19
|
+
run: python -m pip install build --user
|
|
20
|
+
- name: Build a binary wheel and a source tarball
|
|
21
|
+
run: python -m build --sdist --wheel --outdir dist/
|
|
22
|
+
- name: Publish to PyPI
|
|
23
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) collect-filter contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-optional-dependencies
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Don't test code that won't load due to missing imports. A pytest plugin to skip tests that require optional dependencies that are not installed.
|
|
5
|
+
Project-URL: Homepage, https://github.com/okken/pytest-optional-dependencies
|
|
6
|
+
Project-URL: Repository, https://github.com/okken/pytest-optional-dependencies
|
|
7
|
+
Project-URL: Issues, https://github.com/okken/pytest-optional-dependencies/issues
|
|
8
|
+
Author: Brian Okken
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: collection,imports,plugin,pytest,testing
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: pytest>=8.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: hatchling>=1.25; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: tox>=4.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pytest-optional-dependencies
|
|
26
|
+
|
|
27
|
+
Don't test code that won't load due to missing imports.
|
|
28
|
+
A pytest plugin to skip tests that require optional dependencies that are not installed.
|
|
29
|
+
|
|
30
|
+
Collection-time optional dependency handling for pytest.
|
|
31
|
+
|
|
32
|
+
This plugin allows specific missing imports to be treated as optional so collection can continue without errors.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
* --optional-dependency MODULE (repeatable, also accepts comma-separated values).
|
|
37
|
+
* specify which dependencies to skip/deselect based on their absence
|
|
38
|
+
* --optional-dependencies-any
|
|
39
|
+
* to treat any missing module import as optional.
|
|
40
|
+
* --optional-dependencies-action
|
|
41
|
+
* to control optional import handling: skip (default) or deselect.
|
|
42
|
+
* Configuration options
|
|
43
|
+
* optional_dependencies
|
|
44
|
+
* optional_dependencies_any
|
|
45
|
+
* optional_dependencies_action
|
|
46
|
+
* --report-optional-dependencies
|
|
47
|
+
* Report what was filtered and why.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv pip install pytest-optional-dependencies
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or with pip:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m pip install pytest-optional-dependencies
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Compatibility
|
|
62
|
+
|
|
63
|
+
- Python: 3.10+
|
|
64
|
+
- pytest: 8.0+
|
|
65
|
+
|
|
66
|
+
## CLI options
|
|
67
|
+
|
|
68
|
+
- --optional-dependency MODULE
|
|
69
|
+
- --optional-dependencies-any
|
|
70
|
+
- --optional-dependencies-action {deselect,skip}
|
|
71
|
+
- --report-optional-dependencies
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
pytest.ini:
|
|
76
|
+
|
|
77
|
+
```ini
|
|
78
|
+
[pytest]
|
|
79
|
+
optional_dependencies =
|
|
80
|
+
optional_dependency
|
|
81
|
+
some_namespace.submodule
|
|
82
|
+
optional_dependencies_any = false
|
|
83
|
+
optional_dependencies_action = skip
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
pyproject.toml:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
[tool.pytest.ini_options]
|
|
90
|
+
optional_dependencies = [
|
|
91
|
+
"optional_dependency",
|
|
92
|
+
"some_namespace.submodule",
|
|
93
|
+
]
|
|
94
|
+
optional_dependencies_any = false
|
|
95
|
+
optional_dependencies_action = "skip"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Example
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pytest -q --optional-dependency optional_dependency --report-optional-dependencies
|
|
102
|
+
pytest -q --optional-dependency optional_dependency --optional-dependencies-action skip
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m pytest -q
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT. See LICENSE.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# pytest-optional-dependencies
|
|
2
|
+
|
|
3
|
+
Don't test code that won't load due to missing imports.
|
|
4
|
+
A pytest plugin to skip tests that require optional dependencies that are not installed.
|
|
5
|
+
|
|
6
|
+
Collection-time optional dependency handling for pytest.
|
|
7
|
+
|
|
8
|
+
This plugin allows specific missing imports to be treated as optional so collection can continue without errors.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
* --optional-dependency MODULE (repeatable, also accepts comma-separated values).
|
|
13
|
+
* specify which dependencies to skip/deselect based on their absence
|
|
14
|
+
* --optional-dependencies-any
|
|
15
|
+
* to treat any missing module import as optional.
|
|
16
|
+
* --optional-dependencies-action
|
|
17
|
+
* to control optional import handling: skip (default) or deselect.
|
|
18
|
+
* Configuration options
|
|
19
|
+
* optional_dependencies
|
|
20
|
+
* optional_dependencies_any
|
|
21
|
+
* optional_dependencies_action
|
|
22
|
+
* --report-optional-dependencies
|
|
23
|
+
* Report what was filtered and why.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv pip install pytest-optional-dependencies
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with pip:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m pip install pytest-optional-dependencies
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Compatibility
|
|
38
|
+
|
|
39
|
+
- Python: 3.10+
|
|
40
|
+
- pytest: 8.0+
|
|
41
|
+
|
|
42
|
+
## CLI options
|
|
43
|
+
|
|
44
|
+
- --optional-dependency MODULE
|
|
45
|
+
- --optional-dependencies-any
|
|
46
|
+
- --optional-dependencies-action {deselect,skip}
|
|
47
|
+
- --report-optional-dependencies
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
pytest.ini:
|
|
52
|
+
|
|
53
|
+
```ini
|
|
54
|
+
[pytest]
|
|
55
|
+
optional_dependencies =
|
|
56
|
+
optional_dependency
|
|
57
|
+
some_namespace.submodule
|
|
58
|
+
optional_dependencies_any = false
|
|
59
|
+
optional_dependencies_action = skip
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
pyproject.toml:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
optional_dependencies = [
|
|
67
|
+
"optional_dependency",
|
|
68
|
+
"some_namespace.submodule",
|
|
69
|
+
]
|
|
70
|
+
optional_dependencies_any = false
|
|
71
|
+
optional_dependencies_action = "skip"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Example
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pytest -q --optional-dependency optional_dependency --report-optional-dependencies
|
|
78
|
+
pytest -q --optional-dependency optional_dependency --optional-dependencies-action skip
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m pytest -q
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT. See LICENSE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Example test files for pytest-optional-dependencies
|
|
2
|
+
|
|
3
|
+
Run from this folder so pytest picks this pyproject.toml:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cd examples
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- pyproject.toml
|
|
10
|
+
- sets optional_dependencies = ["missing_dependency", "vendor_only_package"]
|
|
11
|
+
- sets optional_dependencies_any = false
|
|
12
|
+
- defaults optional missing imports to skip reporting behavior.
|
|
13
|
+
|
|
14
|
+
- test_bad_dependency.py
|
|
15
|
+
- imports bad_dependency.
|
|
16
|
+
- default behavior is a collection error.
|
|
17
|
+
- use --optional-dependency bad_dependency to skip collection for the file.
|
|
18
|
+
|
|
19
|
+
- test_optional_dependency.py
|
|
20
|
+
- imports missing_dependency.
|
|
21
|
+
- because missing_dependency is listed as optional in config, it is reported as skipped and no collection error is raised.
|
|
22
|
+
|
|
23
|
+
- test_simple.py
|
|
24
|
+
- ordinary passing test for baseline behavior.
|
|
25
|
+
|
|
26
|
+
Use --report-optional-dependencies to print optional-dependency policy and per-file skip reasons.
|
|
27
|
+
Use --optional-dependencies-action deselect to override the default and hide optional missing imports from skip counts.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-optional-dependencies"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Don't test code that won't load due to missing imports. A pytest plugin to skip tests that require optional dependencies that are not installed."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["pytest>=8.0"]
|
|
13
|
+
authors = [{ name = "Brian Okken" }]
|
|
14
|
+
keywords = ["pytest", "plugin", "collection", "imports", "testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/okken/pytest-optional-dependencies"
|
|
25
|
+
Repository = "https://github.com/okken/pytest-optional-dependencies"
|
|
26
|
+
Issues = "https://github.com/okken/pytest-optional-dependencies/issues"
|
|
27
|
+
|
|
28
|
+
[project.entry-points.pytest11]
|
|
29
|
+
optional-dependencies = "pytest_optional_dependencies.plugin"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8.0", "tox>=4.0", "hatchling>=1.25"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["coverage[toml]>=7.0", "ruff>=0.6.0", "build>=1.2.0", "twine>=5.1.0"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
pythonpath = ["src"]
|
|
39
|
+
addopts = "-ra"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
pytester_example_dir = "examples"
|
|
42
|
+
|
|
43
|
+
[tool.coverage.run]
|
|
44
|
+
branch = true
|
|
45
|
+
source = ["pytest_optional_dependencies", "tests"]
|
|
46
|
+
concurrency = ["multiprocessing"]
|
|
47
|
+
parallel = true
|
|
48
|
+
sigterm = true
|
|
49
|
+
|
|
50
|
+
[tool.coverage.report]
|
|
51
|
+
fail_under = 100
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest-optional-dependencies: Handle missing imports gracefully during test collection.
|
|
3
|
+
|
|
4
|
+
This plugin allows tests to be skipped or deselected if they fail collection due to
|
|
5
|
+
missing optional dependency imports. This is useful when a package has optional extras
|
|
6
|
+
that may not be installed in all test environments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Use pytest's StashKey for thread-safe storage of plugin state
|
|
13
|
+
FILTER_EVENTS_KEY = pytest.StashKey[list[str]]()
|
|
14
|
+
ACCEPTABLE_MISSING_MODULES_KEY = pytest.StashKey[set[str]]()
|
|
15
|
+
OPTIONAL_DEPENDENCIES_ANY_KEY = pytest.StashKey[bool]()
|
|
16
|
+
OPTIONAL_DEPENDENCIES_ACTION_KEY = pytest.StashKey[str]()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _DeselectedCollectorNode:
|
|
20
|
+
"""Minimal object for pytest_deselected accounting."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, nodeid):
|
|
23
|
+
self.nodeid = nodeid
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _extract_missing_module_name(longrepr_text):
|
|
27
|
+
"""Extract the module name from a ModuleNotFoundError/ImportError message.
|
|
28
|
+
|
|
29
|
+
Why: pytest's longreprtext contains the full error traceback. We need to parse
|
|
30
|
+
the specific error message to extract just the missing module name. The error
|
|
31
|
+
message format is: "No module named 'module.name'" with either single or double
|
|
32
|
+
quotes depending on Python version and context.
|
|
33
|
+
"""
|
|
34
|
+
no_module_prefix = "No module named "
|
|
35
|
+
if no_module_prefix not in longrepr_text:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
missing_part = longrepr_text.split(no_module_prefix, 1)[1].strip()
|
|
39
|
+
if not missing_part:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Extract the quoted module name. The quote character (single or double) indicates
|
|
43
|
+
# where the module name begins, and we find the closing quote.
|
|
44
|
+
quote = missing_part[0]
|
|
45
|
+
if quote in {'"', "'"}:
|
|
46
|
+
end_idx = missing_part.find(quote, 1)
|
|
47
|
+
if end_idx > 1:
|
|
48
|
+
return missing_part[1:end_idx]
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_module_names(raw_values):
|
|
53
|
+
"""Convert raw config values into a normalized set of module names.
|
|
54
|
+
|
|
55
|
+
Why: Config values can come from either CLI (--optional-dependency flag, can be
|
|
56
|
+
repeated) or ini file (comma-separated lists). We need to handle both formats
|
|
57
|
+
uniformly. CLI passes a list, ini passes strings. This function flattens them
|
|
58
|
+
and handles comma-separated values so users can write either:
|
|
59
|
+
optional_dependencies = numpy,scipy
|
|
60
|
+
optional_dependencies = numpy
|
|
61
|
+
scipy
|
|
62
|
+
in their ini file, or use --optional-dependency multiple times on the CLI.
|
|
63
|
+
"""
|
|
64
|
+
modules = set()
|
|
65
|
+
for value in raw_values:
|
|
66
|
+
if not value:
|
|
67
|
+
continue
|
|
68
|
+
for part in str(value).split(","):
|
|
69
|
+
module = part.strip()
|
|
70
|
+
if module:
|
|
71
|
+
modules.add(module)
|
|
72
|
+
return modules
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_optional_missing_module(report, config):
|
|
76
|
+
"""Check if a collection failure is due to an optional missing import.
|
|
77
|
+
|
|
78
|
+
Why this function exists: During collection, if a test module imports an optional
|
|
79
|
+
dependency that's not installed, the entire test collection fails. We need to:
|
|
80
|
+
1. Detect if the failure was actually due to a missing import (not another error)
|
|
81
|
+
2. Extract which module was missing
|
|
82
|
+
3. Check if that module is in our list of acceptable-to-skip missing modules
|
|
83
|
+
|
|
84
|
+
Returns: The missing module name if it's an optional dependency we should skip,
|
|
85
|
+
or None if this failure shouldn't be handled by this plugin.
|
|
86
|
+
"""
|
|
87
|
+
if not report.failed:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
longrepr_text = getattr(report, "longreprtext", "")
|
|
91
|
+
# Check for ModuleNotFoundError or ImportError - only then is a missing module
|
|
92
|
+
# the root cause. Other import errors (syntax errors, etc.) shouldn't be skipped.
|
|
93
|
+
if (
|
|
94
|
+
"ImportError" not in longrepr_text
|
|
95
|
+
and "ModuleNotFoundError" not in longrepr_text
|
|
96
|
+
):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
missing_module = _extract_missing_module_name(longrepr_text)
|
|
100
|
+
if not missing_module:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# If optional_dependencies_any is set, skip ANY missing module import error.
|
|
104
|
+
# This is useful for test environments where many optional deps might be missing.
|
|
105
|
+
if config.stash.get(OPTIONAL_DEPENDENCIES_ANY_KEY, False):
|
|
106
|
+
return missing_module
|
|
107
|
+
|
|
108
|
+
# Treat submodules as acceptable if top-level package is listed.
|
|
109
|
+
# Why: If user specifies "sklearn" as optional, they likely mean sklearn and all
|
|
110
|
+
# its submodules (sklearn.ensemble, sklearn.preprocessing, etc.). Without this,
|
|
111
|
+
# a test importing sklearn.ensemble would fail even if sklearn is listed.
|
|
112
|
+
optional_dependencies = config.stash.get(ACCEPTABLE_MISSING_MODULES_KEY, set())
|
|
113
|
+
top_level = missing_module.split(".", 1)[0]
|
|
114
|
+
if missing_module in optional_dependencies or top_level in optional_dependencies:
|
|
115
|
+
return missing_module
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _record_filter_event(config, message):
|
|
120
|
+
"""Record a filtering decision for the debug report if --report-optional-dependencies is set.
|
|
121
|
+
|
|
122
|
+
Why: Users can pass --report-optional-dependencies to see which tests were skipped and why.
|
|
123
|
+
This helps them verify the plugin is working as intended and debug any issues.
|
|
124
|
+
"""
|
|
125
|
+
if config.getoption("report_optional_dependencies"):
|
|
126
|
+
config.stash[FILTER_EVENTS_KEY].append(message)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
130
|
+
def pytest_make_collect_report(collector):
|
|
131
|
+
"""Intercept collection failures and convert optional-dependency failures to skips/passes.
|
|
132
|
+
|
|
133
|
+
Why tryfirst=True: We need to run before other plugins that might fail on import errors.
|
|
134
|
+
Why hookwrapper=True: We need to intercept the report AFTER collection happens but BEFORE
|
|
135
|
+
pytest processes it further. This allows us to change the outcome from "failed" to "skipped".
|
|
136
|
+
"""
|
|
137
|
+
outcome = yield
|
|
138
|
+
report = outcome.get_result()
|
|
139
|
+
|
|
140
|
+
missing_module = _get_optional_missing_module(report, collector.config)
|
|
141
|
+
if missing_module:
|
|
142
|
+
action = collector.config.stash.get(OPTIONAL_DEPENDENCIES_ACTION_KEY, "skip")
|
|
143
|
+
_record_filter_event(
|
|
144
|
+
collector.config,
|
|
145
|
+
f"{report.nodeid}: missing module '{missing_module}' is optional ({action})",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if action == "skip":
|
|
149
|
+
# Mark as skipped so the test still appears in output (good for visibility)
|
|
150
|
+
report.outcome = "skipped"
|
|
151
|
+
report.longrepr = (
|
|
152
|
+
str(collector.path),
|
|
153
|
+
0,
|
|
154
|
+
f"missing module '{missing_module}' is configured as optional",
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
# Deselect collection node so it is reflected in pytest deselected counts.
|
|
158
|
+
collector.config.hook.pytest_deselected(
|
|
159
|
+
items=[_DeselectedCollectorNode(report.nodeid)]
|
|
160
|
+
)
|
|
161
|
+
report.outcome = "passed"
|
|
162
|
+
report.longrepr = None
|
|
163
|
+
outcome.force_result(report)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def pytest_addoption(parser):
|
|
167
|
+
"""Register command-line and ini-file options for this plugin."""
|
|
168
|
+
group = parser.getgroup("Optional dependencies")
|
|
169
|
+
|
|
170
|
+
# CLI options for specifying optional dependencies (can be used multiple times)
|
|
171
|
+
group.addoption(
|
|
172
|
+
"--optional-dependency",
|
|
173
|
+
action="append",
|
|
174
|
+
default=[],
|
|
175
|
+
metavar="MODULE",
|
|
176
|
+
help="Treat a missing module as an optional dependency during collection",
|
|
177
|
+
)
|
|
178
|
+
group.addoption(
|
|
179
|
+
"--optional-dependencies-any",
|
|
180
|
+
action="store_true",
|
|
181
|
+
default=False,
|
|
182
|
+
help="Treat any missing-module import as optional during collection",
|
|
183
|
+
)
|
|
184
|
+
group.addoption(
|
|
185
|
+
"--optional-dependencies-action",
|
|
186
|
+
action="store",
|
|
187
|
+
default=None,
|
|
188
|
+
choices=("deselect", "skip"),
|
|
189
|
+
help="How to report optional missing imports: skip (default) or deselect",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Ini file options (alternative to CLI for permanent project configuration)
|
|
193
|
+
parser.addini(
|
|
194
|
+
"optional_dependencies",
|
|
195
|
+
"Optional dependencies that may be missing during collection import",
|
|
196
|
+
type="linelist",
|
|
197
|
+
default=[],
|
|
198
|
+
)
|
|
199
|
+
parser.addini(
|
|
200
|
+
"optional_dependencies_any",
|
|
201
|
+
"If true, treat any missing-module import as optional during collection",
|
|
202
|
+
type="bool",
|
|
203
|
+
default=False,
|
|
204
|
+
)
|
|
205
|
+
parser.addini(
|
|
206
|
+
"optional_dependencies_action",
|
|
207
|
+
"How optional missing imports are reported: skip (default) or deselect",
|
|
208
|
+
default="skip",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Reporting option
|
|
212
|
+
if not getattr(parser, "_pytest_optional_dependencies_report_option_added", False):
|
|
213
|
+
group.addoption(
|
|
214
|
+
"--report-optional-dependencies",
|
|
215
|
+
action="store_true",
|
|
216
|
+
default=False,
|
|
217
|
+
help="Report optional-dependency collection decisions and their reasons",
|
|
218
|
+
)
|
|
219
|
+
parser._pytest_optional_dependencies_report_option_added = True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def pytest_configure(config):
|
|
223
|
+
"""Initialize plugin state at the start of the test session.
|
|
224
|
+
|
|
225
|
+
Why: We need to prepare the config.stash with initial values before collection starts,
|
|
226
|
+
and also parse/merge CLI options with ini file settings. CLI options have priority.
|
|
227
|
+
"""
|
|
228
|
+
config.stash[FILTER_EVENTS_KEY] = []
|
|
229
|
+
|
|
230
|
+
# Merge optional dependencies from both ini file and CLI (CLI takes precedence)
|
|
231
|
+
configured_missing_imports = _normalize_module_names(
|
|
232
|
+
config.getini("optional_dependencies")
|
|
233
|
+
)
|
|
234
|
+
cli_missing_imports = _normalize_module_names(
|
|
235
|
+
config.getoption("optional_dependency")
|
|
236
|
+
)
|
|
237
|
+
config.stash[ACCEPTABLE_MISSING_MODULES_KEY] = (
|
|
238
|
+
configured_missing_imports | cli_missing_imports
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Set the "treat any missing import" flag if either CLI or ini is enabled
|
|
242
|
+
config.stash[OPTIONAL_DEPENDENCIES_ANY_KEY] = bool(
|
|
243
|
+
config.getini("optional_dependencies_any")
|
|
244
|
+
) or bool(config.getoption("optional_dependencies_any"))
|
|
245
|
+
|
|
246
|
+
# Determine action (skip or deselect) - CLI takes precedence over ini file
|
|
247
|
+
action = config.getoption("optional_dependencies_action") or config.getini(
|
|
248
|
+
"optional_dependencies_action"
|
|
249
|
+
)
|
|
250
|
+
if action not in {"deselect", "skip"}:
|
|
251
|
+
raise pytest.UsageError(
|
|
252
|
+
"optional_dependencies_action must be either 'deselect' or 'skip'"
|
|
253
|
+
)
|
|
254
|
+
config.stash[OPTIONAL_DEPENDENCIES_ACTION_KEY] = action
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def pytest_collection_finish(session):
|
|
258
|
+
"""Print debug report about optional dependencies if --report-optional-dependencies was set.
|
|
259
|
+
|
|
260
|
+
Why: Users need visibility into what the plugin did. This report shows the configured
|
|
261
|
+
policy and a log of every collection decision made, helping them debug issues.
|
|
262
|
+
"""
|
|
263
|
+
config = session.config
|
|
264
|
+
if not config.getoption("report_optional_dependencies"):
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
optional_dependencies_any = config.stash.get(OPTIONAL_DEPENDENCIES_ANY_KEY, False)
|
|
268
|
+
optional_dependencies = sorted(
|
|
269
|
+
config.stash.get(ACCEPTABLE_MISSING_MODULES_KEY, set())
|
|
270
|
+
)
|
|
271
|
+
action = config.stash.get(OPTIONAL_DEPENDENCIES_ACTION_KEY, "skip")
|
|
272
|
+
|
|
273
|
+
print("optional dependency policy:")
|
|
274
|
+
print(f" optional dependencies any: {optional_dependencies_any}")
|
|
275
|
+
print(f" optional dependencies action: {action}")
|
|
276
|
+
if optional_dependencies:
|
|
277
|
+
print(" optional dependencies: " + ", ".join(optional_dependencies))
|
|
278
|
+
else:
|
|
279
|
+
print(" optional dependencies: (none)")
|
|
280
|
+
|
|
281
|
+
events = config.stash.get(FILTER_EVENTS_KEY, [])
|
|
282
|
+
print("optional dependency report:")
|
|
283
|
+
if not events:
|
|
284
|
+
print(" no optional imports were skipped")
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
for event in events:
|
|
288
|
+
print(f" - {event}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_plugins = ("pytester",)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Unit tests for internal helper functions in the optional-dependencies plugin."""
|
|
2
|
+
|
|
3
|
+
from pytest_optional_dependencies.plugin import (
|
|
4
|
+
_get_optional_missing_module,
|
|
5
|
+
_extract_missing_module_name,
|
|
6
|
+
_normalize_module_names,
|
|
7
|
+
pytest_addoption,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_extract_missing_module_name_no_prefix():
|
|
12
|
+
"""Line 28: return None when 'No module named' is not in the text."""
|
|
13
|
+
assert _extract_missing_module_name("SomeOtherError: something went wrong") is None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_extract_missing_module_name_empty_after_prefix():
|
|
17
|
+
"""Line 32: return None when nothing follows 'No module named '."""
|
|
18
|
+
assert _extract_missing_module_name("No module named ") is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_extract_missing_module_name_unquoted():
|
|
22
|
+
"""Line 41: return None when the module name is not quoted."""
|
|
23
|
+
assert _extract_missing_module_name("No module named bad_module") is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_extract_missing_module_name_empty_quoted():
|
|
27
|
+
"""Cover the branch where quotes exist but no module name is inside them."""
|
|
28
|
+
assert _extract_missing_module_name("No module named ''") is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_extract_missing_module_name_single_quoted():
|
|
32
|
+
"""Happy path: single-quoted module name is extracted correctly."""
|
|
33
|
+
assert _extract_missing_module_name("No module named 'bad_module'") == "bad_module"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extract_missing_module_name_double_quoted():
|
|
37
|
+
"""Happy path: double-quoted module name is extracted correctly."""
|
|
38
|
+
assert _extract_missing_module_name('No module named "bad_module"') == "bad_module"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_normalize_module_names_empty_string():
|
|
42
|
+
"""Line 59: empty string values are skipped (continue branch)."""
|
|
43
|
+
result = _normalize_module_names(["", "good_module"])
|
|
44
|
+
assert result == {"good_module"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_normalize_module_names_trailing_comma():
|
|
48
|
+
"""Line 62->60: parts that strip to empty string are skipped."""
|
|
49
|
+
result = _normalize_module_names(["good_module,"])
|
|
50
|
+
assert result == {"good_module"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_normalize_module_names_comma_only():
|
|
54
|
+
"""Both empty-value and empty-part branches: comma-only value."""
|
|
55
|
+
result = _normalize_module_names([","])
|
|
56
|
+
assert result == set()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_optional_missing_module_returns_none_when_name_not_extracted():
|
|
60
|
+
"""Line 90: return None when ImportError text has no extractable module name."""
|
|
61
|
+
|
|
62
|
+
class Report:
|
|
63
|
+
failed = True
|
|
64
|
+
longreprtext = "ImportError: cannot import name something"
|
|
65
|
+
|
|
66
|
+
class Config:
|
|
67
|
+
stash = {}
|
|
68
|
+
|
|
69
|
+
assert _get_optional_missing_module(Report(), Config()) is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_pytest_addoption_skips_shared_report_option_when_already_added():
|
|
73
|
+
"""Line 199->exit: guard prevents adding duplicate --report option."""
|
|
74
|
+
|
|
75
|
+
class Group:
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self.options = []
|
|
78
|
+
|
|
79
|
+
def addoption(self, *args, **kwargs):
|
|
80
|
+
self.options.append((args, kwargs))
|
|
81
|
+
|
|
82
|
+
class Parser:
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self._pytest_optional_dependencies_report_option_added = True
|
|
85
|
+
self.ini = []
|
|
86
|
+
self.group = Group()
|
|
87
|
+
|
|
88
|
+
def getgroup(self, _name):
|
|
89
|
+
return self.group
|
|
90
|
+
|
|
91
|
+
def addini(self, *args, **kwargs):
|
|
92
|
+
self.ini.append((args, kwargs))
|
|
93
|
+
|
|
94
|
+
parser = Parser()
|
|
95
|
+
pytest_addoption(parser)
|
|
96
|
+
|
|
97
|
+
added_option_names = [opt_args[0] for (opt_args, _kwargs) in parser.group.options]
|
|
98
|
+
assert "--report-optional-dependencies" not in added_option_names
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_missing_is_error_with_no_flag(pytester):
|
|
5
|
+
pytester.copy_example("test_simple.py")
|
|
6
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
7
|
+
|
|
8
|
+
result = pytester.runpytest("-v")
|
|
9
|
+
result.stdout.fnmatch_lines(
|
|
10
|
+
[
|
|
11
|
+
"E ModuleNotFoundError: No module named 'bad_dependency'*",
|
|
12
|
+
]
|
|
13
|
+
)
|
|
14
|
+
result.assert_outcomes(errors=1)
|
|
15
|
+
assert result.ret == 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_missing_is_not_collected_with_flag(pytester):
|
|
19
|
+
pytester.copy_example("test_simple.py")
|
|
20
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
21
|
+
|
|
22
|
+
result = pytester.runpytest(
|
|
23
|
+
"-v",
|
|
24
|
+
"--optional-dependency",
|
|
25
|
+
"bad_dependency",
|
|
26
|
+
)
|
|
27
|
+
result.stdout.fnmatch_lines(
|
|
28
|
+
[
|
|
29
|
+
"*collected 1 item / 1 skipped*",
|
|
30
|
+
"test_simple.py::test_simple PASSED*",
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
result.assert_outcomes(passed=1, skipped=1)
|
|
34
|
+
assert result.ret == 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_only_optional_missing_module_is_skipped_by_default(pytester):
|
|
38
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
39
|
+
|
|
40
|
+
result = pytester.runpytest(
|
|
41
|
+
"-v",
|
|
42
|
+
"--optional-dependency",
|
|
43
|
+
"bad_dependency",
|
|
44
|
+
)
|
|
45
|
+
result.assert_outcomes(skipped=1)
|
|
46
|
+
assert result.ret == 5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_report(pytester):
|
|
50
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
51
|
+
|
|
52
|
+
result = pytester.runpytest(
|
|
53
|
+
"-q",
|
|
54
|
+
"--optional-dependency",
|
|
55
|
+
"bad_dependency",
|
|
56
|
+
"--report-optional-dependencies",
|
|
57
|
+
)
|
|
58
|
+
result.stdout.fnmatch_lines(
|
|
59
|
+
[
|
|
60
|
+
"optional dependency policy:",
|
|
61
|
+
" optional dependencies any: False",
|
|
62
|
+
" optional dependencies action: skip",
|
|
63
|
+
"*optional dependencies:*bad_dependency*",
|
|
64
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (skip)",
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_report_any(pytester):
|
|
70
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
71
|
+
|
|
72
|
+
result = pytester.runpytest(
|
|
73
|
+
"-q",
|
|
74
|
+
"--optional-dependencies-any",
|
|
75
|
+
"--report-optional-dependencies",
|
|
76
|
+
)
|
|
77
|
+
result.stdout.fnmatch_lines(
|
|
78
|
+
[
|
|
79
|
+
"optional dependency policy:",
|
|
80
|
+
" optional dependencies any: True",
|
|
81
|
+
" optional dependencies action: skip",
|
|
82
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (skip)",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_ini_optional_dependencies_any(pytester):
|
|
88
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
89
|
+
pytester.makepyprojecttoml(
|
|
90
|
+
textwrap.dedent("""
|
|
91
|
+
[tool.pytest.ini_options]
|
|
92
|
+
optional_dependencies_any = true
|
|
93
|
+
""")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
result = pytester.runpytest()
|
|
97
|
+
result.assert_outcomes(skipped=1)
|
|
98
|
+
assert result.ret == 5
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_ini_optional_dependency(pytester):
|
|
102
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
103
|
+
pytester.makepyprojecttoml(
|
|
104
|
+
textwrap.dedent("""
|
|
105
|
+
[tool.pytest.ini_options]
|
|
106
|
+
optional_dependencies = ["bad_dependency"]
|
|
107
|
+
""")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
result = pytester.runpytest(
|
|
111
|
+
"-q",
|
|
112
|
+
"--report-optional-dependencies",
|
|
113
|
+
)
|
|
114
|
+
result.assert_outcomes(skipped=1)
|
|
115
|
+
assert result.ret == 5
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_multiple_optional_dependencies_via_cli(pytester):
|
|
119
|
+
pytester.copy_example("test_simple.py")
|
|
120
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
121
|
+
pytester.copy_example("test_optional_dependency.py")
|
|
122
|
+
|
|
123
|
+
result = pytester.runpytest(
|
|
124
|
+
"-v",
|
|
125
|
+
"--optional-dependency",
|
|
126
|
+
"bad_dependency",
|
|
127
|
+
"--optional-dependency",
|
|
128
|
+
"missing_dependency",
|
|
129
|
+
"--report-optional-dependencies",
|
|
130
|
+
)
|
|
131
|
+
result.stdout.fnmatch_lines(
|
|
132
|
+
[
|
|
133
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (skip)",
|
|
134
|
+
"*test_optional_dependency.py: missing module 'missing_dependency' is optional (skip)",
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
assert result.ret == 0
|
|
138
|
+
result.assert_outcomes(passed=1, skipped=2)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_multiple_optional_dependencies_via_ini(pytester):
|
|
142
|
+
pytester.makepyprojecttoml(
|
|
143
|
+
textwrap.dedent("""
|
|
144
|
+
[tool.pytest.ini_options]
|
|
145
|
+
optional_dependencies = [
|
|
146
|
+
"missing_dependency",
|
|
147
|
+
"bad_dependency",
|
|
148
|
+
]
|
|
149
|
+
""")
|
|
150
|
+
)
|
|
151
|
+
pytester.copy_example("test_simple.py")
|
|
152
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
153
|
+
pytester.copy_example("test_optional_dependency.py")
|
|
154
|
+
|
|
155
|
+
result = pytester.runpytest(
|
|
156
|
+
"-v",
|
|
157
|
+
"--report-optional-dependencies",
|
|
158
|
+
)
|
|
159
|
+
result.stdout.fnmatch_lines(
|
|
160
|
+
[
|
|
161
|
+
"*collected 1 item / 2 skipped*",
|
|
162
|
+
"*optional dependencies:*bad_dependency*missing_dependency*",
|
|
163
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (skip)",
|
|
164
|
+
"*test_optional_dependency.py: missing module 'missing_dependency' is optional (skip)",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
assert result.ret == 0
|
|
168
|
+
result.assert_outcomes(passed=1, skipped=2)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_action_deselect_via_cli_reports_deselected(pytester):
|
|
172
|
+
pytester.copy_example("test_simple.py")
|
|
173
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
174
|
+
|
|
175
|
+
result = pytester.runpytest(
|
|
176
|
+
"-q",
|
|
177
|
+
"--optional-dependency",
|
|
178
|
+
"bad_dependency",
|
|
179
|
+
"--optional-dependencies-action",
|
|
180
|
+
"deselect",
|
|
181
|
+
"--report-optional-dependencies",
|
|
182
|
+
)
|
|
183
|
+
result.stdout.fnmatch_lines(
|
|
184
|
+
[
|
|
185
|
+
"optional dependency policy:",
|
|
186
|
+
" optional dependencies action: deselect",
|
|
187
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (deselect)",
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
result.assert_outcomes(passed=1, deselected=1)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_report_with_no_skips(pytester):
|
|
194
|
+
"""Exercise the 'no optional imports were skipped' path in pytest_collection_finish."""
|
|
195
|
+
pytester.copy_example("test_simple.py")
|
|
196
|
+
|
|
197
|
+
result = pytester.runpytest(
|
|
198
|
+
"-q",
|
|
199
|
+
"--optional-dependency",
|
|
200
|
+
"bad_dependency",
|
|
201
|
+
"--report-optional-dependencies",
|
|
202
|
+
)
|
|
203
|
+
result.stdout.fnmatch_lines(["*no optional imports were skipped*"])
|
|
204
|
+
result.assert_outcomes(passed=1)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_invalid_action_raises_usage_error(pytester):
|
|
208
|
+
"""Exercise the UsageError for invalid optional_dependencies_action value."""
|
|
209
|
+
pytester.copy_example("test_simple.py")
|
|
210
|
+
pytester.makepyprojecttoml(
|
|
211
|
+
textwrap.dedent("""
|
|
212
|
+
[tool.pytest.ini_options]
|
|
213
|
+
optional_dependencies_action = "invalid"
|
|
214
|
+
""")
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
result = pytester.runpytest("-q")
|
|
218
|
+
result.stderr.fnmatch_lines(
|
|
219
|
+
["*optional_dependencies_action must be either 'deselect' or 'skip'*"]
|
|
220
|
+
)
|
|
221
|
+
assert result.ret != 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_non_import_error_is_not_skipped(pytester):
|
|
225
|
+
"""Exercise the path where the collection error is not an ImportError."""
|
|
226
|
+
pytester.makepyfile(
|
|
227
|
+
textwrap.dedent("""
|
|
228
|
+
raise ValueError("not an import error")
|
|
229
|
+
|
|
230
|
+
def test_something():
|
|
231
|
+
pass
|
|
232
|
+
""")
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
result = pytester.runpytest("-q", "--optional-dependencies-any")
|
|
236
|
+
result.assert_outcomes(errors=1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_normalize_empty_and_comma_values(pytester):
|
|
240
|
+
"""Exercise _normalize_module_names with empty/trailing-comma values."""
|
|
241
|
+
pytester.copy_example("test_simple.py")
|
|
242
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
243
|
+
pytester.makepyprojecttoml(
|
|
244
|
+
textwrap.dedent("""
|
|
245
|
+
[tool.pytest.ini_options]
|
|
246
|
+
optional_dependencies = ["bad_dependency,", ","]
|
|
247
|
+
""")
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
result = pytester.runpytest("-q")
|
|
251
|
+
result.assert_outcomes(passed=1, skipped=1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_action_deselect_via_ini_reports_deselected(pytester):
|
|
255
|
+
pytester.copy_example("test_simple.py")
|
|
256
|
+
pytester.copy_example("test_bad_dependency.py")
|
|
257
|
+
pytester.makepyprojecttoml(
|
|
258
|
+
textwrap.dedent("""
|
|
259
|
+
[tool.pytest.ini_options]
|
|
260
|
+
optional_dependencies = ["bad_dependency"]
|
|
261
|
+
optional_dependencies_action = "deselect"
|
|
262
|
+
""")
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
result = pytester.runpytest("-q", "--report-optional-dependencies")
|
|
266
|
+
result.stdout.fnmatch_lines(
|
|
267
|
+
[
|
|
268
|
+
"*optional dependencies action: deselect*",
|
|
269
|
+
"*test_bad_dependency.py: missing module 'bad_dependency' is optional (deselect)",
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
result.assert_outcomes(passed=1, deselected=1)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[tox]
|
|
2
|
+
envlist = py310,py311,py312,py313,py314,lint,format,build,twine
|
|
3
|
+
isolated_build = true
|
|
4
|
+
skip_missing_interpreters = true
|
|
5
|
+
|
|
6
|
+
[testenv]
|
|
7
|
+
description = Run optional-dependencies test suite with pytest
|
|
8
|
+
deps =
|
|
9
|
+
pytest>=8.0
|
|
10
|
+
coverage[toml]
|
|
11
|
+
commands =
|
|
12
|
+
coverage run -m pytest -q
|
|
13
|
+
coverage combine
|
|
14
|
+
coverage report
|
|
15
|
+
|
|
16
|
+
[testenv:lint]
|
|
17
|
+
description = Run Ruff checks
|
|
18
|
+
skip_install = true
|
|
19
|
+
deps =
|
|
20
|
+
ruff>=0.6.0
|
|
21
|
+
commands =
|
|
22
|
+
ruff check src tests examples
|
|
23
|
+
|
|
24
|
+
[testenv:format]
|
|
25
|
+
description = Check Ruff formatting
|
|
26
|
+
skip_install = true
|
|
27
|
+
deps =
|
|
28
|
+
ruff>=0.6.0
|
|
29
|
+
commands =
|
|
30
|
+
ruff format --check src tests examples
|
|
31
|
+
|
|
32
|
+
[testenv:build]
|
|
33
|
+
description = Build source and wheel distributions
|
|
34
|
+
skip_install = true
|
|
35
|
+
deps =
|
|
36
|
+
build>=1.2.0
|
|
37
|
+
commands =
|
|
38
|
+
python -m build
|
|
39
|
+
|
|
40
|
+
[testenv:twine]
|
|
41
|
+
description = Validate distribution metadata with twine
|
|
42
|
+
skip_install = true
|
|
43
|
+
deps =
|
|
44
|
+
twine>=5.1.0
|
|
45
|
+
commands =
|
|
46
|
+
python -m twine check dist/*
|