pytest-optional-dependencies 0.1.2__py3-none-any.whl

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,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,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,7 @@
1
+ pytest_optional_dependencies/__init__.py,sha256=X5R-DJs-iCNJxlpaPEDAFf2IcvJvMgpO6yJE4NQp-t0,100
2
+ pytest_optional_dependencies/plugin.py,sha256=Th4UbGFjb6GHF8R18Bno9oSbMFbS5kpWKQleURoY7aw,11294
3
+ pytest_optional_dependencies-0.1.2.dist-info/METADATA,sha256=7IVqPQGaQ6cR4d-0aX5bjG2DYRX55ylR2Qs8_1Fs4gE,2993
4
+ pytest_optional_dependencies-0.1.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ pytest_optional_dependencies-0.1.2.dist-info/entry_points.txt,sha256=zcUj5PVlA6DWSC7Rn58QQtIMCx_oxOOB0EPnx7gyiVQ,71
6
+ pytest_optional_dependencies-0.1.2.dist-info/licenses/LICENSE,sha256=0J6JKEVXQiGte_mgexMhnvLhrPiYlfNZGOi3ASq_2l0,1079
7
+ pytest_optional_dependencies-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ optional-dependencies = pytest_optional_dependencies.plugin
@@ -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.