Coverage for src/pytest_samples/plugin/_bootstrap.py: 100%
100 statements
« prev ^ index » next coverage.py v7.4.2, created at 2024-02-20 19:47 +0000
« prev ^ index » next coverage.py v7.4.2, created at 2024-02-20 19:47 +0000
1import argparse as _argparse
2import os.path as _ospath
3import pytest as _pytest
4import pytimeparse as _pytimeparse
6from datetime import timedelta as _timedelta
7from pytest import Config as _Config, Parser as _Parser
8from typing import NoReturn as _NoReturn, Optional as _Optional, \
9 Type as _Type
11from ._broker_stateful import StatefulSamplesBroker as _StatefulSamplesBroker
12from . import _broker_nostate
13from . import _broker_stateful
14from . import _meta
17class CmdFlags:
18 """Contains the command line flags."""
20 mode = f"--{_meta.PLUGIN_NAME}"
21 """The flag to enable and set the mode of operation of the
22 plugin.
23 """
25 db_path = f"--{_meta.PLUGIN_NAME}-db-path"
26 """The flag for the state database path."""
28 soft_timeout = f"--{_meta.PLUGIN_NAME}-soft-timeout"
29 """The flag for the soft timeout time."""
31 hash_testfiles = f"--{_meta.PLUGIN_NAME}-hash-testfiles"
32 """The flag for whether to hash test files."""
34 seed = f"--{_meta.PLUGIN_NAME}-seed"
35 """The flag for the seed."""
37 write_immediately = f"--{_meta.PLUGIN_NAME}-write-immediately"
38 """The flag for the option to write the state immediately."""
40 randomize = f"--{_meta.PLUGIN_NAME}-randomize"
41 """The flag for a randomized test order in state mode."""
43 no_pruning = f"--{_meta.PLUGIN_NAME}-no-pruning"
44 """The flag for keeping no longer existent tests in the database."""
46 nostate_seeded = f"--{_meta.PLUGIN_NAME}-nostate-seeded"
47 """The flag for keeping no longer existent tests in the database."""
49 reset_on_saturation = f"--{_meta.PLUGIN_NAME}-reset-on-saturation"
50 """The flag to enable a reset after all tests have been stored in
51 the database and therefore passed at least once.
52 """
54 overwrite_broken_db = f"--{_meta.PLUGIN_NAME}-overwrite-broken-db"
55 """Flag used to enable overwriting broken database files in
56 stateful mode.
57 """
59 enable_db_logging = f"--{_meta.PLUGIN_NAME}-enable-db-logging"
60 """Flag to enable logging of the underlying database engine/
61 implementation.
62 """
65class _ModeKeys:
66 """Contains the keys (names) of the modes of operations for the
67 plugin.
68 """
70 nostate = "nostate"
71 """The mode in which no state information regarding the tests is
72 recorded.
73 """
75 stateful = "stateful"
76 """The mode in which information regarding the tests is recorded in
77 a database.
78 """
81class ConfigWarning(UserWarning):
82 """A warning that is issued in connection with config issues."""
83 pass
86class SeedSetInNoStateMode(ConfigWarning):
87 """A warning that is issued if a seed value is provided in "no
88 state" mode.
89 """
90 pass
93class SamplesBrokerBootstrap:
95 @classmethod
96 def _parse_soft_timeout( # pragma: no cover
97 cls, value: str
98 ) -> _Optional[_timedelta]:
99 """Parses the soft timeout value provided to the command line.
101 Args:
102 value (str): The provided value.
104 Raises:
105 ArgumentTypeError: If a valid `timedelta` could not be
106 created due to a malformed string.
108 Returns:
109 Optional[timedelta]: The created `timedelta` instance or
110 None, if the time should be infinite.
111 """
112 if value == "off":
113 return None
114 seconds = _pytimeparse.parse(value)
115 if seconds is None:
116 raise _argparse.ArgumentTypeError(
117 f"Could not parse a valid time delta from {value!r}."
118 )
119 return _timedelta(seconds=seconds)
121 def pytest_addoption(self, parser: _Parser) -> None:
122 """The function called for the pytest "addoption" hook to add
123 options for this plugin.
125 Args:
126 parser (Parser): The parser.
127 pluginmanager (PytestPluginManager): The plugin manager.
128 """
129 pname = _meta.PLUGIN_NAME
130 g = parser.getgroup(
131 f"--{pname}", _meta.PLUGIN_FULL_NAME
132 )
133 g.addoption(
134 CmdFlags.mode,
135 help=(
136 "Enable the samples plugin to only test some tests "
137 "(samples). If not enabled, all other flags of this "
138 "plugin will be ignored (not checked). The provided "
139 "argument will determine the mode of operation for the "
140 "plugin. In \"nostate\" mode, the tests will simply be "
141 "shuffled to allow different tests to run first with "
142 "each run. In \"stateful\" mode a database path must "
143 f"be provided with {CmdFlags.db_path}. A database "
144 "containing information about the last passed tests "
145 "will be saved to this path to allow failed and "
146 "never run tests to be run before passed tests."
147 ),
148 choices=(_ModeKeys.nostate, _ModeKeys.stateful)
149 )
150 g.addoption(
151 CmdFlags.db_path,
152 help=(
153 "Path to the database file in which to store the "
154 "information regarding tests that have been run. This "
155 "file will be modified. If the file does not exist, it "
156 "will be created. This argument must not be set in nostate "
157 "mode. Relative paths will be interpreted with respect "
158 "to the current working directory."
159 f"{CmdFlags.mode}={_ModeKeys.nostate}."
160 ),
161 metavar="db_path"
162 )
163 g.addoption(
164 CmdFlags.soft_timeout,
165 help=(
166 "Set the soft timeout time. Once it expires, all remaining "
167 "tests will be marked as \"skipped\". Whether the timeout "
168 "has expired is only checked after each test, meaning "
169 "that running tests will not be stopped by the timeout. "
170 "Defaults to 50 minutes. Supports all formats defined "
171 "by the pytimeparse package. Can be set to \"off\" to "
172 "deactivate the soft timeout."
173 ),
174 default=_timedelta(minutes=50),
175 metavar="soft_timeout",
176 type=self._parse_soft_timeout
177 )
178 g.addoption(
179 CmdFlags.hash_testfiles,
180 help=(
181 "Use hashes of the files containing the tests in an "
182 "attempt to detect changes. This does not guarantee a"
183 "perfect detection of changes, for example when fixtures "
184 "defined externally change or when hash collisions "
185 "occur."
186 ),
187 action="store_true",
188 default=False
189 )
190 g.addoption(
191 CmdFlags.seed,
192 help=(
193 "The seed to be used for random numbers. If this is set "
194 "in \"nostate\" mode a warning will be emitted, unless "
195 f"{CmdFlags.nostate_seeded} is set because each test run "
196 "would be identical. The provided value will be passed "
197 "directly as a string to the python RNG."
198 ),
199 default=None,
200 metavar="seed",
201 type=str
202 )
203 g.addoption(
204 CmdFlags.write_immediately,
205 help=(
206 "If this flag is set, the database will be updated after "
207 "every successful test. Otherwise, it will only be updated "
208 "after all tests are finished (or have been skipped). "
209 "This flag is ignored in \"nostate\" mode."
210 ),
211 action="store_true",
212 default=False
213 )
214 g.addoption(
215 CmdFlags.randomize,
216 help=(
217 "If this flag is set, the tests which have not yet been "
218 "run before will be run in a randomized order. This flag "
219 "will be ignored in \"nostate\" mode."
220 ),
221 action="store_true",
222 default=False
223 )
224 g.addoption(
225 CmdFlags.no_pruning,
226 help=(
227 "If this flag is set, tests that are no longer found "
228 "will not be removed from the database. This flag will "
229 "be ignored in \"nostate\" mode."
230 ),
231 action="store_true",
232 default=False
233 )
234 g.addoption(
235 CmdFlags.nostate_seeded,
236 help=(
237 "Suppress the warning regarding fixed seeds in "
238 "\"nostate\" mode. This flag will be ignored in "
239 "\"stateful\" mode."
240 ),
241 action="store_true",
242 default=False
243 )
244 g.addoption(
245 CmdFlags.reset_on_saturation,
246 help=(
247 "Drop all entries from the database once all tests are "
248 "stored in it and therefore have passed at least once. "
249 "This is especially useful in combination with "
250 f"{CmdFlags.randomize} with a random seed. If all tests "
251 "pass, the database will also be left empty. This flag will "
252 "be ignored in \"nostate\" mode."
253 ),
254 action="store_true",
255 default=False
256 )
257 g.addoption(
258 CmdFlags.overwrite_broken_db,
259 help=(
260 "Overwrite broken database files in stateful mode. "
261 "WARNING: This may delete the database file and can lead to "
262 "DATA LOSS! This flag will be ignored in \"nostate\" mode."
263 ),
264 action="store_true",
265 default=False
266 )
267 g.addoption(
268 CmdFlags.enable_db_logging,
269 help=(
270 "Enable database related logging such as logging of SQL "
271 "statements. This flag will be ignored in \"nostate\" mode."
272 ),
273 action="store_true",
274 default=False
275 )
277 def _cmdline_error(self, message: str) -> _NoReturn: # pragma: no cover
278 """Print the command line error message from the command line
279 parser.
281 Args:
282 config (Config): The config.
283 message (str): The error message.
285 Raises:
286 RuntimeError: If exiting via the argument parser failed, for
287 example, if the instance could not be obtained.
288 """
289 raise _pytest.UsageError(message)
291 def pytest_configure(self, config: _Config) -> None:
292 """The pytest hook called to configure the plugin.
294 Args:
295 config (Config): The config.
296 """
297 pluginmanager = config.pluginmanager
298 _meta.unregister(pluginmanager, self)
300 mode = config.getoption(CmdFlags.mode)
301 if mode is None:
302 # Not enabled, since no mode was provided.
303 return
305 db_path: _Optional[str] = config.getoption(CmdFlags.db_path)
307 soft_timeout = config.getoption(CmdFlags.soft_timeout)
308 seed = config.getoption(CmdFlags.seed)
310 if mode == _ModeKeys.nostate:
311 if db_path is not None:
312 self._cmdline_error( # pragma: no cover
313 "The mode was set to "
314 f"{CmdFlags.mode}={_ModeKeys.nostate}, but"
315 f"{CmdFlags.db_path} was provided."
316 )
317 if seed is not None:
318 is_ok = config.getoption(CmdFlags.nostate_seeded)
319 if not is_ok:
320 warning = SeedSetInNoStateMode(
321 "A seed value was provided in \"no state\" mode. "
322 "This is only recommended when testing the plugin "
323 "itself since each test iteration will be "
324 "identical if the and some tests may not get run. "
325 "If you know what you are doing, additionally "
326 f"pass the {CmdFlags.nostate_seeded} flag to "
327 "suppress this warning."
328 )
329 config.issue_config_time_warning(warning, 2)
330 p = _broker_nostate.NoStateSamplesBroker(soft_timeout, seed)
331 _meta.register(pluginmanager, p)
332 return
334 if mode != _ModeKeys.stateful:
335 raise AssertionError(
336 f"An invalid mode key was provided: {mode!r}"
337 )
338 if db_path is None:
339 self._cmdline_error( # pragma: no cover
340 "The mode was set to "
341 f"{CmdFlags.mode}={_ModeKeys.stateful}, but "
342 "no database path was provided via "
343 f"{CmdFlags.db_path}."
344 )
346 db_path = _ospath.realpath(db_path)
347 hash_testfiles = config.getoption(CmdFlags.hash_testfiles)
348 randomize = config.getoption(CmdFlags.randomize)
349 no_pruning = config.getoption(CmdFlags.no_pruning)
350 reset_on_saturation = config.getoption(CmdFlags.reset_on_saturation)
351 overwrite_broken_db = config.getoption(CmdFlags.overwrite_broken_db)
352 enable_db_logging = config.getoption(CmdFlags.enable_db_logging)
353 write_immediately = config.getoption(CmdFlags.write_immediately)
355 rootpath = str(config.rootpath)
357 if enable_db_logging: # pragma: no branch
358 # We make this import here because the plugin might not
359 # actually get enabled. But once we are here, a database
360 # connection will be inevitable and we can enable logging.
361 from ..database import enable_logging
362 enable_logging()
364 plugin_cls: _Type[_StatefulSamplesBroker]
365 if write_immediately:
366 plugin_cls = _broker_stateful.ImmediateStatefulSamplesBroker
367 else:
368 plugin_cls = _broker_stateful.LazyStatefulSamplesBroker
370 plugin = plugin_cls(
371 rootpath,
372 soft_timeout,
373 seed,
374 db_path,
375 hash_testfiles,
376 randomize,
377 no_pruning,
378 reset_on_saturation,
379 overwrite_broken_db
380 )
382 _meta.register(pluginmanager, plugin)