poodle 1.0.0__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.
poodle/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Poodle Mutation Test Tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class PoodleInputError(ValueError):
13
+ """An input value from Command Line, poodle_config.py, or a config file was invalid."""
14
+
15
+
16
+ class PoodleTrialRunError(Exception):
17
+ """An unexpected error occurred when running a mutation trial or clean run."""
18
+
19
+
20
+ try:
21
+ import tomllib # type: ignore [import-not-found]
22
+ except ModuleNotFoundError: # < py3.11
23
+ import tomli as tomllib # type: ignore [no-redef]
24
+
25
+
26
+ poodle_config: Any = None
27
+ with suppress(ImportError):
28
+ sys.path.append(str(Path.cwd()))
29
+ poodle_config = importlib.import_module("poodle_config")
poodle/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """Command Line Interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from . import PoodleInputError, core
11
+ from .config import build_config
12
+
13
+ CONTEXT_SETTINGS = {
14
+ "max_content_width": 120,
15
+ }
16
+
17
+
18
+ @click.command(context_settings=CONTEXT_SETTINGS)
19
+ @click.argument("sources", type=click.Path(exists=True, path_type=Path), nargs=-1)
20
+ @click.option("-c", "config_file", help="Configuration File.", type=click.Path(exists=True, path_type=Path))
21
+ @click.option("-q", "quiet", help="Quiet mode: q, qq, or qq", count=True)
22
+ @click.option("-v", "verbose", help="Verbose mode: v, vv, or vvv", count=True)
23
+ @click.option("-w", "workers", help="Maximum number of parallel workers.", type=int)
24
+ @click.option("--exclude", help="Add a regex exclude file filter. Multiple allowed.", multiple=True)
25
+ @click.option("--only", help="Glob pattern for files to mutate. Multiple allowed.", multiple=True)
26
+ def main(
27
+ sources: tuple[Path],
28
+ config_file: Path | None,
29
+ quiet: int,
30
+ verbose: int,
31
+ workers: int | None,
32
+ exclude: tuple[str],
33
+ only: tuple[str],
34
+ ) -> None:
35
+ """Poodle Mutation Test Tool."""
36
+ try:
37
+ config = build_config(sources, config_file, quiet, verbose, workers, exclude, only)
38
+ except PoodleInputError as err:
39
+ click.echo(err.args)
40
+ sys.exit(4)
41
+
42
+ try:
43
+ core.main(config)
44
+ except KeyboardInterrupt:
45
+ click.echo("Aborted due to Keyboard Interrupt!")
46
+ sys.exit(2)
47
+
48
+
49
+ # pytest return codes
50
+ # Exit code 0: All tests were collected and passed successfully
51
+ # Exit code 1: Tests were collected and run but some of the tests failed
52
+ # Exit code 2: Test execution was interrupted by the user
53
+ # Exit code 3: Internal error happened while executing tests
54
+ # Exit code 4: pytest command line usage error
55
+ # Exit code 5: No tests were collected
56
+
57
+ if __name__ == "__main__":
58
+ main()
poodle/config.py ADDED
@@ -0,0 +1,480 @@
1
+ """Resolve configuration options and build PoodleConfig object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from wcmatch import glob
12
+
13
+ from . import PoodleInputError, poodle_config, tomllib
14
+ from .data_types import PoodleConfig
15
+
16
+ default_source_folders = [Path("src"), Path("lib")]
17
+
18
+ default_log_format = "%(levelname)s [%(process)d] %(name)s.%(funcName)s:%(lineno)d - %(message)s"
19
+ default_log_level = logging.WARN
20
+
21
+ default_file_flags = glob.GLOBSTAR | glob.NODIR
22
+ default_file_filters = ["test_*.py", "*_test.py"]
23
+ default_file_copy_flags = glob.GLOBSTAR | glob.NODIR
24
+ default_file_copy_filters = ["test_*.py", "*_test.py", "__pycache__/**"]
25
+ default_work_folder = Path(".poodle-temp")
26
+
27
+ default_mutator_opts: dict[str, Any] = {}
28
+
29
+ default_min_timeout = 10
30
+ default_timeout_multiplier = 10
31
+ default_runner = "command_line"
32
+ default_runner_opts: dict[str, Any] = {"command_line": "pytest -x --assert=plain -o pythonpath="}
33
+
34
+ default_reporters = ["summary", "not_found"]
35
+ default_reporter_opts: dict[str, Any] = {}
36
+
37
+
38
+ def default_max_workers() -> int:
39
+ """Calculate Default for max_workers as one less than available processors."""
40
+ if hasattr(os, "sched_getaffinity"):
41
+ return len(os.sched_getaffinity(0)) - 1
42
+ cpu_count = os.cpu_count() or 1 # nomut: Number
43
+ if cpu_count > 1:
44
+ return cpu_count - 1
45
+ return cpu_count
46
+
47
+
48
+ def build_config( # noqa: PLR0913
49
+ cmd_sources: tuple[Path],
50
+ cmd_config_file: Path | None,
51
+ cmd_quiet: int,
52
+ cmd_verbose: int,
53
+ cmd_max_workers: int | None,
54
+ cmd_excludes: tuple[str],
55
+ cmd_only_files: tuple[str],
56
+ ) -> PoodleConfig:
57
+ """Build PoodleConfig object."""
58
+ config_file_path = get_config_file_path(cmd_config_file)
59
+ config_file_data = get_config_file_data(config_file_path)
60
+
61
+ log_format = get_str_from_config("log_format", config_file_data, default=default_log_format)
62
+ log_level: int | str = get_any_from_config(
63
+ "log_level",
64
+ config_file_data,
65
+ default=default_log_level,
66
+ command_line=get_cmd_line_log_level(cmd_quiet, cmd_verbose),
67
+ )
68
+ logging.basicConfig(format=log_format, level=log_level)
69
+
70
+ file_filters = get_str_list_from_config("file_filters", config_file_data, default=default_file_filters)
71
+ # TODO: append file excludes and append py excludes
72
+ # file_filters += get_str_list_from_config("exclude", config_file_data, default=[]) # noqa: ERA001
73
+ file_filters += cmd_excludes
74
+
75
+ return PoodleConfig(
76
+ config_file=config_file_path,
77
+ source_folders=get_source_folders(cmd_sources, config_file_data),
78
+ only_files=get_str_list_from_config("only_files", config_file_data, default=[], command_line=cmd_only_files),
79
+ file_flags=get_int_from_config("file_flags", config_file_data, default=default_file_flags),
80
+ file_filters=file_filters,
81
+ file_copy_flags=get_int_from_config("file_copy_flags", config_file_data, default=default_file_copy_flags),
82
+ file_copy_filters=get_str_list_from_config(
83
+ "file_copy_filters",
84
+ config_file_data,
85
+ default=default_file_copy_filters,
86
+ ),
87
+ work_folder=get_path_from_config("work_folder", config_file_data, default=default_work_folder),
88
+ max_workers=get_int_from_config(
89
+ "max_workers",
90
+ config_file_data,
91
+ default=default_max_workers(),
92
+ command_line=cmd_max_workers,
93
+ ),
94
+ log_format=log_format,
95
+ log_level=log_level,
96
+ echo_enabled=get_bool_from_config(
97
+ "echo_enabled",
98
+ config_file_data,
99
+ default=True,
100
+ command_line=get_cmd_line_echo_enabled(cmd_quiet),
101
+ ),
102
+ echo_no_color=get_bool_from_config("echo_no_color", config_file_data),
103
+ mutator_opts=get_dict_from_config("mutator_opts", config_file_data, default=default_mutator_opts),
104
+ skip_mutators=get_str_list_from_config("skip_mutators", config_file_data, default=[]),
105
+ add_mutators=get_any_list_from_config("add_mutators", config_file_data),
106
+ min_timeout=get_int_from_config("min_timeout", config_file_data) or default_min_timeout,
107
+ timeout_multiplier=get_int_from_config("timeout_multiplier", config_file_data) or default_timeout_multiplier,
108
+ runner=get_str_from_config("runner", config_file_data, default=default_runner),
109
+ runner_opts=get_dict_from_config("runner_opts", config_file_data, default=default_runner_opts),
110
+ reporters=get_str_list_from_config("reporters", config_file_data, default=default_reporters),
111
+ reporter_opts=get_dict_from_config("reporter_opts", config_file_data, default=default_reporter_opts),
112
+ )
113
+
114
+
115
+ def get_cmd_line_log_level(cmd_quiet: int, cmd_verbose: int) -> int | None:
116
+ """Map verbosity input to logging level."""
117
+ if cmd_quiet >= 3:
118
+ return logging.CRITICAL
119
+ if cmd_quiet == 2:
120
+ return logging.ERROR
121
+ if cmd_quiet == 1:
122
+ return logging.WARN
123
+
124
+ if cmd_verbose == 1:
125
+ return logging.INFO
126
+ if cmd_verbose == 2:
127
+ return logging.DEBUG
128
+ if cmd_verbose >= 3:
129
+ return logging.NOTSET
130
+
131
+ return None
132
+
133
+
134
+ def get_cmd_line_echo_enabled(cmd_quiet: int) -> bool | None:
135
+ """Map verbosity input to enable/disable echo statements."""
136
+ return cmd_quiet < 1
137
+
138
+
139
+ def get_config_file_path(config_file: Path | None) -> Path | None:
140
+ """Identify which configuration file to use.
141
+
142
+ Checks in this order, first value is used.
143
+ Command Line, poodle.toml, pyproject.toml.
144
+ Returns None if no config file found.
145
+ """
146
+ if config_file:
147
+ if not config_file.is_file():
148
+ msg = f"Config file not found: '{config_file}'"
149
+ raise PoodleInputError(msg)
150
+ return config_file
151
+
152
+ if hasattr(poodle_config, "config_file"):
153
+ config_file = Path(poodle_config.config_file)
154
+ if not config_file.is_file():
155
+ msg = f"config_file not found: '{poodle_config.config_file}'"
156
+ raise PoodleInputError(msg)
157
+ return config_file
158
+
159
+ files = [
160
+ "poodle.toml",
161
+ "pyproject.toml",
162
+ ] # TODO: ["poodle.toml", "tox.ini", "setup.cfg", "pyproject.toml"]
163
+
164
+ for file in files:
165
+ path = Path(file)
166
+ if path.is_file():
167
+ return path
168
+
169
+ return None
170
+
171
+
172
+ def get_config_file_data(config_file: Path | None) -> dict:
173
+ """Retrieve Poodle configuration from specified Config File."""
174
+ if not config_file:
175
+ return {}
176
+
177
+ if config_file.suffix == ".toml":
178
+ return get_config_file_data_toml(config_file)
179
+
180
+ # TODO: tox.ini and setup.cfg
181
+ # https://tox.wiki/en/3.24.5/config.html
182
+
183
+ msg = f"Config file type not supported: --config_file='{config_file}'"
184
+ raise PoodleInputError(msg)
185
+
186
+
187
+ def get_config_file_data_toml(config_file: Path) -> dict:
188
+ """Retrieve Poodle configuration from a 'toml' Config File."""
189
+ config_data = tomllib.load(config_file.open(mode="rb"))
190
+ config_data = config_data.get("tool", config_data)
191
+ return config_data.get("poodle", {})
192
+
193
+
194
+ def get_source_folders(command_line_sources: tuple[Path], config_data: dict) -> list[Path]:
195
+ """Retrieve list of source folders that contain files to mutate.
196
+
197
+ Verifies that all returned values are existing directories.
198
+ """
199
+ source_folders = get_path_list_from_config(
200
+ option_name="source_folders",
201
+ config_data=config_data,
202
+ command_line=command_line_sources,
203
+ default=[source for source in default_source_folders if source.is_dir()],
204
+ )
205
+
206
+ if not source_folders:
207
+ raise PoodleInputError("No source folder found to mutate.")
208
+
209
+ for source in source_folders:
210
+ if not source.is_dir():
211
+ msg = f"Source '{source}' must be a folder."
212
+ raise PoodleInputError(msg)
213
+
214
+ return source_folders
215
+
216
+
217
+ def get_bool_from_config(
218
+ option_name: str,
219
+ config_data: dict,
220
+ default: bool | None = None,
221
+ command_line: bool | str | None = None,
222
+ ) -> bool | None:
223
+ """Retrieve Config Option that should be a Boolean.
224
+
225
+ Retrieve highest priority value from config sources.
226
+ """
227
+ value, _ = get_option_from_config(option_name=option_name, config_data=config_data, command_line=command_line)
228
+
229
+ if isinstance(value, bool):
230
+ return value
231
+
232
+ if isinstance(value, str):
233
+ return value.upper() == "TRUE"
234
+
235
+ return default
236
+
237
+
238
+ def get_path_from_config(
239
+ option_name: str,
240
+ config_data: dict,
241
+ default: Path,
242
+ command_line: Path | None = None,
243
+ ) -> Path:
244
+ """Retrieve Config Option that should be a String.
245
+
246
+ Retrieve highest priority value from config sources.
247
+ """
248
+ value, source = get_option_from_config(option_name=option_name, config_data=config_data, command_line=command_line)
249
+
250
+ if value is None:
251
+ return default
252
+
253
+ try:
254
+ return Path(value)
255
+ except TypeError:
256
+ msg = f"{option_name} from {source} must be a valid StrPath"
257
+ raise PoodleInputError(msg) from None
258
+
259
+
260
+ def get_path_list_from_config(
261
+ option_name: str,
262
+ config_data: dict,
263
+ default: list[Path] | None = None,
264
+ command_line: tuple[Path] | None = None,
265
+ ) -> list[Path]:
266
+ """Retrieve Config Option that should be a List of Paths.
267
+
268
+ Retrieve highest priority value from config sources.
269
+ If input was a single Path, return as a list of Paths.
270
+ Convert input Iterable to List.
271
+ """
272
+ default_fix = default or []
273
+ command_line_fix = command_line or ()
274
+
275
+ values, source = get_option_from_config(
276
+ option_name=option_name,
277
+ config_data=config_data,
278
+ command_line=command_line_fix,
279
+ )
280
+
281
+ if not values:
282
+ return default_fix
283
+
284
+ try:
285
+ if isinstance(values, Path):
286
+ return [values]
287
+
288
+ if isinstance(values, str):
289
+ return [Path(values)]
290
+
291
+ return [Path(value) for value in values]
292
+ except TypeError:
293
+ msg = f"{option_name} from {source} must be a valid Iterable[StrPath]"
294
+ raise PoodleInputError(msg) from None
295
+
296
+
297
+ def get_any_from_config(
298
+ option_name: str,
299
+ config_data: dict,
300
+ default: Any = None, # noqa: ANN401
301
+ command_line: Any | None = None, # noqa: ANN401
302
+ ) -> Any: # noqa: ANN401
303
+ """Retrieve Config Option that can by any type.
304
+
305
+ Retrieve highest priority value from config sources.
306
+ """
307
+ value, _ = get_option_from_config(option_name=option_name, config_data=config_data, command_line=command_line)
308
+
309
+ if value is None:
310
+ return default
311
+ return value
312
+
313
+
314
+ def get_any_list_from_config(
315
+ option_name: str,
316
+ config_data: dict,
317
+ default: list[Any] | None = None,
318
+ command_line: tuple[Any] | None = None,
319
+ ) -> list[Any]:
320
+ """Retrieve Config Option that should be a List of any types.
321
+
322
+ Retrieve highest priority value from config sources.
323
+ Convert input Iterable to List.
324
+ """
325
+ default_fix = default or []
326
+ command_line_fix = command_line or ()
327
+
328
+ values, _ = get_option_from_config(
329
+ option_name=option_name,
330
+ config_data=config_data,
331
+ command_line=command_line_fix,
332
+ )
333
+
334
+ if values is None:
335
+ return default_fix
336
+
337
+ if isinstance(values, str):
338
+ return [values]
339
+
340
+ if isinstance(values, Iterable):
341
+ return list(values)
342
+
343
+ return [values]
344
+
345
+
346
+ def get_int_from_config(
347
+ option_name: str,
348
+ config_data: dict,
349
+ default: int | None = None,
350
+ command_line: int | None = None,
351
+ ) -> int | None:
352
+ """Retrieve Config Option that should be an int or None.
353
+
354
+ Retrieve highest priority value from config sources.
355
+ """
356
+ value, source = get_option_from_config(option_name=option_name, config_data=config_data, command_line=command_line)
357
+
358
+ if value is None:
359
+ return default
360
+
361
+ try:
362
+ return int(value)
363
+ except ValueError:
364
+ msg = f"{option_name} from {source} must be a valid int"
365
+ raise PoodleInputError(msg) from None
366
+
367
+
368
+ def get_str_from_config(
369
+ option_name: str,
370
+ config_data: dict,
371
+ default: str = "",
372
+ command_line: str = "",
373
+ ) -> str:
374
+ """Retrieve Config Option that should be a String.
375
+
376
+ Retrieve highest priority value from config sources.
377
+ """
378
+ value, _ = get_option_from_config(option_name=option_name, config_data=config_data, command_line=command_line)
379
+
380
+ if value is None:
381
+ return default
382
+
383
+ return str(value)
384
+
385
+
386
+ def get_str_list_from_config(
387
+ option_name: str,
388
+ config_data: dict,
389
+ default: list[str] | None = None,
390
+ command_line: str | tuple[str] | None = None,
391
+ ) -> list[str]:
392
+ """Retrieve Config Option that should be a List of Strings.
393
+
394
+ Retrieve highest priority value from config sources.
395
+ If input was a single string, return as a list of strings.
396
+ Convert input Iterable to List.
397
+ """
398
+ default_fix = default or []
399
+ command_line_fix = command_line or ()
400
+
401
+ values, source = get_option_from_config(
402
+ option_name=option_name,
403
+ config_data=config_data,
404
+ command_line=command_line_fix,
405
+ )
406
+
407
+ if not values:
408
+ return default_fix
409
+
410
+ if isinstance(values, str):
411
+ return [values]
412
+
413
+ try:
414
+ return [str(value) for value in values]
415
+ except TypeError:
416
+ msg = f"{option_name} from {source} must be a valid Iterable[str]"
417
+ raise PoodleInputError(msg) from None
418
+
419
+
420
+ def get_option_from_config(
421
+ option_name: str,
422
+ config_data: dict,
423
+ command_line: Any, # noqa: ANN401
424
+ ) -> tuple[Any | None, str | None]:
425
+ """Retrieve Config Option of any type.
426
+
427
+ Check sources in priority order, and return the first one found.
428
+
429
+ Priority Order: Command Line, poodle_config.py, config file
430
+
431
+ Returns: Identified Config value, Source Name
432
+ """
433
+ if command_line or command_line is False:
434
+ return command_line, "Command Line"
435
+
436
+ if hasattr(poodle_config, option_name):
437
+ return getattr(poodle_config, option_name), "poodle_config.py"
438
+
439
+ if option_name in config_data:
440
+ return config_data[option_name], "config file"
441
+
442
+ return None, None
443
+
444
+
445
+ def get_dict_from_config(
446
+ option_name: str,
447
+ config_data: dict,
448
+ default: dict | None = None,
449
+ command_line: dict | None = None,
450
+ ) -> dict:
451
+ """Merge Key-Value pairs from Config sources to a dict.
452
+
453
+ Builds dict by starting with values from lowest priority source.
454
+ Then 'updating' them with values from higher priority sources.
455
+
456
+ Priority Order: Command Line, poodle_config.py, config file, defaults
457
+ """
458
+ default = default or {}
459
+ command_line = command_line or {}
460
+
461
+ option_value = default
462
+
463
+ if option_name in config_data:
464
+ try:
465
+ option_value.update(config_data[option_name])
466
+ except ValueError:
467
+ msg = f"{option_name} in config file must be a valid dict"
468
+ raise PoodleInputError(msg) from None
469
+
470
+ if hasattr(poodle_config, option_name):
471
+ try:
472
+ option_value.update(getattr(poodle_config, option_name))
473
+ except ValueError:
474
+ msg = f"poodle_config.{option_name} must be a valid dict"
475
+ raise PoodleInputError(msg) from None
476
+
477
+ if command_line:
478
+ option_value.update(command_line)
479
+
480
+ return option_value
poodle/core.py ADDED
@@ -0,0 +1,63 @@
1
+ """Poodle core run process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ from typing import TYPE_CHECKING
8
+
9
+ import click
10
+
11
+ from . import PoodleInputError, PoodleTrialRunError
12
+ from .data_types import MutantTrial, PoodleConfig, PoodleWork
13
+ from .mutate import create_mutants_for_all_mutators, initialize_mutators
14
+ from .report import generate_reporters
15
+ from .run import clean_run_each_source_folder, get_runner, run_mutant_trails
16
+ from .util import create_temp_zips, pprint_str
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def main(config: PoodleConfig) -> None:
25
+ """Poodle core run process."""
26
+ try:
27
+ work = PoodleWork(config) # sets logging defaults
28
+ logger.info("\n%s", pprint_str(config))
29
+
30
+ if config.work_folder.exists():
31
+ logger.info("delete %s", config.work_folder)
32
+ shutil.rmtree(config.work_folder)
33
+
34
+ create_temp_zips(work)
35
+
36
+ work.mutators = initialize_mutators(work)
37
+ work.runner = get_runner(config)
38
+ work.reporters = list(generate_reporters(config))
39
+
40
+ mutants = create_mutants_for_all_mutators(work)
41
+ work.echo(f"Identified {len(mutants)} mutants")
42
+
43
+ clean_run_results = clean_run_each_source_folder(work)
44
+ timeout = calc_timeout(config, clean_run_results)
45
+ results = run_mutant_trails(work, mutants, timeout)
46
+
47
+ for reporter in work.reporters:
48
+ reporter(config=config, echo=work.echo, testing_results=results)
49
+
50
+ logger.info("delete %s", config.work_folder)
51
+ shutil.rmtree(config.work_folder)
52
+ except PoodleInputError as err:
53
+ for arg in err.args:
54
+ click.echo(arg)
55
+ except PoodleTrialRunError as err:
56
+ for arg in err.args:
57
+ click.echo(arg)
58
+
59
+
60
+ def calc_timeout(config: PoodleConfig, clean_run_results: dict[Path, MutantTrial]) -> float:
61
+ """Determine timeout value to use in runner."""
62
+ max_clean_run = max([trial.duration for trial in clean_run_results.values()])
63
+ return max(float(max_clean_run) * config.timeout_multiplier, config.min_timeout)
@@ -0,0 +1,5 @@
1
+ """Data Types."""
2
+
3
+ from .data import FileMutation, Mutant, MutantTrial, MutantTrialResult, PoodleConfig, TestingResults, TestingSummary
4
+ from .interfaces import Mutator
5
+ from .work import PoodleWork