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

1import argparse as _argparse 

2import os.path as _ospath 

3import pytest as _pytest 

4import pytimeparse as _pytimeparse 

5 

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 

10 

11from ._broker_stateful import StatefulSamplesBroker as _StatefulSamplesBroker 

12from . import _broker_nostate 

13from . import _broker_stateful 

14from . import _meta 

15 

16 

17class CmdFlags: 

18 """Contains the command line flags.""" 

19 

20 mode = f"--{_meta.PLUGIN_NAME}" 

21 """The flag to enable and set the mode of operation of the 

22 plugin. 

23 """ 

24 

25 db_path = f"--{_meta.PLUGIN_NAME}-db-path" 

26 """The flag for the state database path.""" 

27 

28 soft_timeout = f"--{_meta.PLUGIN_NAME}-soft-timeout" 

29 """The flag for the soft timeout time.""" 

30 

31 hash_testfiles = f"--{_meta.PLUGIN_NAME}-hash-testfiles" 

32 """The flag for whether to hash test files.""" 

33 

34 seed = f"--{_meta.PLUGIN_NAME}-seed" 

35 """The flag for the seed.""" 

36 

37 write_immediately = f"--{_meta.PLUGIN_NAME}-write-immediately" 

38 """The flag for the option to write the state immediately.""" 

39 

40 randomize = f"--{_meta.PLUGIN_NAME}-randomize" 

41 """The flag for a randomized test order in state mode.""" 

42 

43 no_pruning = f"--{_meta.PLUGIN_NAME}-no-pruning" 

44 """The flag for keeping no longer existent tests in the database.""" 

45 

46 nostate_seeded = f"--{_meta.PLUGIN_NAME}-nostate-seeded" 

47 """The flag for keeping no longer existent tests in the database.""" 

48 

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 """ 

53 

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 """ 

58 

59 enable_db_logging = f"--{_meta.PLUGIN_NAME}-enable-db-logging" 

60 """Flag to enable logging of the underlying database engine/ 

61 implementation. 

62 """ 

63 

64 

65class _ModeKeys: 

66 """Contains the keys (names) of the modes of operations for the 

67 plugin. 

68 """ 

69 

70 nostate = "nostate" 

71 """The mode in which no state information regarding the tests is 

72 recorded. 

73 """ 

74 

75 stateful = "stateful" 

76 """The mode in which information regarding the tests is recorded in 

77 a database. 

78 """ 

79 

80 

81class ConfigWarning(UserWarning): 

82 """A warning that is issued in connection with config issues.""" 

83 pass 

84 

85 

86class SeedSetInNoStateMode(ConfigWarning): 

87 """A warning that is issued if a seed value is provided in "no 

88 state" mode. 

89 """ 

90 pass 

91 

92 

93class SamplesBrokerBootstrap: 

94 

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. 

100 

101 Args: 

102 value (str): The provided value. 

103 

104 Raises: 

105 ArgumentTypeError: If a valid `timedelta` could not be 

106 created due to a malformed string. 

107 

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) 

120 

121 def pytest_addoption(self, parser: _Parser) -> None: 

122 """The function called for the pytest "addoption" hook to add 

123 options for this plugin. 

124 

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 ) 

276 

277 def _cmdline_error(self, message: str) -> _NoReturn: # pragma: no cover 

278 """Print the command line error message from the command line 

279 parser. 

280 

281 Args: 

282 config (Config): The config. 

283 message (str): The error message. 

284 

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) 

290 

291 def pytest_configure(self, config: _Config) -> None: 

292 """The pytest hook called to configure the plugin. 

293 

294 Args: 

295 config (Config): The config. 

296 """ 

297 pluginmanager = config.pluginmanager 

298 _meta.unregister(pluginmanager, self) 

299 

300 mode = config.getoption(CmdFlags.mode) 

301 if mode is None: 

302 # Not enabled, since no mode was provided. 

303 return 

304 

305 db_path: _Optional[str] = config.getoption(CmdFlags.db_path) 

306 

307 soft_timeout = config.getoption(CmdFlags.soft_timeout) 

308 seed = config.getoption(CmdFlags.seed) 

309 

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 

333 

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 ) 

345 

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) 

354 

355 rootpath = str(config.rootpath) 

356 

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() 

363 

364 plugin_cls: _Type[_StatefulSamplesBroker] 

365 if write_immediately: 

366 plugin_cls = _broker_stateful.ImmediateStatefulSamplesBroker 

367 else: 

368 plugin_cls = _broker_stateful.LazyStatefulSamplesBroker 

369 

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 ) 

381 

382 _meta.register(pluginmanager, plugin)