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.
@@ -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,3 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: okken
@@ -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,15 @@
1
+ *.egg-info
2
+ *.pyc
3
+ .tox
4
+ dist/
5
+ __pycache__
6
+ .coverage
7
+ .idea/
8
+ cov_html/
9
+ venv/
10
+ README.html
11
+ build/
12
+ htmlcov/
13
+ *.swp
14
+ .python-version
15
+ .vscode/
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## Unreleased
6
+
7
+ ### Nothing her yet
8
+
9
+ ## 0.1.2
10
+
11
+ ### Added
12
+
13
+ - Initial optional-dependencies plugin.
14
+
@@ -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,4 @@
1
+ [tool.pytest.ini_options]
2
+ optional_dependencies = ["missing_dependency", "vendor_only_package"]
3
+ optional_dependencies_any = false
4
+ optional_dependencies_action = "skip"
@@ -0,0 +1,5 @@
1
+ import bad_dependency
2
+
3
+
4
+ def test_bad_dependency():
5
+ bad_dependency.some_function()
@@ -0,0 +1,5 @@
1
+ import missing_dependency
2
+
3
+
4
+ def test_optional_dependency():
5
+ missing_dependency.some_function()
@@ -0,0 +1,2 @@
1
+ def test_simple():
2
+ assert True
@@ -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,4 @@
1
+ """pytest-optional-dependencies plugin package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
@@ -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/*