pytest-flakefighters 0.2.3__py3-none-any.whl → 0.3.1__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.
_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.3'
32
- __version_tuple__ = version_tuple = (0, 2, 3)
31
+ __version__ = version = '0.3.1'
32
+ __version_tuple__ = version_tuple = (0, 3, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -0,0 +1,90 @@
1
+ """
2
+ This module defines all the configuration options in a dictionary.
3
+ Keys should be of the form `(--long-name, -L)` or just `(--long-name,)`.
4
+ Options can then be specified on the commandline as `--long-name` or in a configuration file as `long_name`.
5
+ Options specified on the commandline will override those specified in configuration files.
6
+ """
7
+
8
+ import os
9
+
10
+ from pytest_flakefighters.rerun_strategies import All, FlakyFailure, PreviouslyFlaky
11
+
12
+ rerun_strategies = {"ALL": All, "FLAKY_FAILURE": FlakyFailure, "PREVIOUSLY_FLAKY": PreviouslyFlaky}
13
+
14
+
15
+ options = {
16
+ ("--root",): {
17
+ "dest": "root",
18
+ "action": "store",
19
+ "default": os.getcwd(),
20
+ "help": "The root directory of the project. Defaults to the current working directory.",
21
+ },
22
+ ("--suppress-flaky-failures-exit-code",): {
23
+ "dest": "suppress_flaky",
24
+ "action": "store_true",
25
+ "default": False,
26
+ "help": "Return OK exit code if the only failures are flaky failures.",
27
+ },
28
+ ("--no-save",): {
29
+ "action": "store_true",
30
+ "default": False,
31
+ "help": "Do not save this run to the database of previous flakefighters runs.",
32
+ },
33
+ ("--function-coverage",): {
34
+ "action": "store_true",
35
+ "default": False,
36
+ "help": "Use function-level coverage instead of line coverage.",
37
+ },
38
+ ("--load-max-runs", "-M"): {
39
+ "action": "store",
40
+ "default": None,
41
+ "help": "The maximum number of previous runs to consider.",
42
+ },
43
+ ("--database-url", "-D"): {
44
+ "action": "store",
45
+ "default": "sqlite:///flakefighters.db",
46
+ "help": "The database URL. Defaults to 'flakefighters.db' in current working directory.",
47
+ },
48
+ ("--store-max-runs",): {
49
+ "action": "store",
50
+ "default": None,
51
+ "type": int,
52
+ "help": "The maximum number of previous flakefighters runs to store. Default is to store all.",
53
+ },
54
+ ("--max-reruns",): {
55
+ "action": "store",
56
+ "default": 0,
57
+ "type": int,
58
+ "help": "The maximum number of times to rerun tests. "
59
+ "By default, only failing tests marked as flaky will be rerun. "
60
+ "This can be changed with the --rerun-strategy parameter.",
61
+ },
62
+ ("--rerun-strategy",): {
63
+ "action": "store",
64
+ "type": str,
65
+ "choices": list(rerun_strategies),
66
+ "default": "FLAKY_FAILURE",
67
+ "help": "The strategy used to determine which tests to rerun. Supported options are:\n "
68
+ + "\n ".join(f"{name} - {strat.help()}" for name, strat in rerun_strategies.items()),
69
+ },
70
+ ("--time-immemorial",): {
71
+ "action": "store",
72
+ "default": None,
73
+ "help": "How long to store flakefighters runs for, specified as `days:hours:minutes`. "
74
+ "E.g. to store tests for one week, use 7:0:0.",
75
+ },
76
+ ("--display-outcomes", "-O"): {
77
+ "action": "store",
78
+ "type": int,
79
+ "nargs": "?", # Allows 0 or 1 arguments
80
+ "const": 0, # Value used if -O is present but no value is provided
81
+ "default": 0, # Value used if -O is not present at all
82
+ "help": "Display historical test outcomes of the specified number of previous runs."
83
+ "If no value is specified, then display only the current verdict.",
84
+ },
85
+ ("--display-verdicts",): {
86
+ "action": "store_true",
87
+ "default": False,
88
+ "help": "Display the flaky classification verdicts alongside test outcomes.",
89
+ },
90
+ }
@@ -34,10 +34,12 @@ from sqlalchemy.orm import (
34
34
  class Base(DeclarativeBase):
35
35
  """
36
36
  Declarative base class for data objects.
37
+
38
+ :ivar id: Unique autoincrementing ID for the object.
37
39
  """
38
40
 
39
41
  id: Mapped[int] = Column(Integer, primary_key=True) # pylint: disable=C0103
40
- # @pytest, these are not the tests you're looking for...
42
+ # Explicitly flag that we don't want pytest to collect our Test, TestExecution, etc. classes.
41
43
  __test__ = False # pylint: disable=C0103
42
44
 
43
45
  @declared_attr
@@ -49,8 +51,16 @@ class Base(DeclarativeBase):
49
51
  class Run(Base):
50
52
  """
51
53
  Class to store attributes of a flakefighters run.
54
+ :ivar start_time: The time the test run was begun.
55
+ :ivar created_at: The time the entry was added to the database.
56
+ This is not necessarily equivalent to start_time if the test suite took a long time to run or
57
+ if the entry was migrated from a separate database.
58
+ :ivar root: The root directory of the project.
59
+ :ivar tests: The test suite.
60
+ :ivar active_flakefighters: The flakefighters that are active on the run.
52
61
  """
53
62
 
63
+ start_time = Column(DateTime)
54
64
  created_at = Column(DateTime, default=func.now())
55
65
  root: Mapped[str] = Column(String)
56
66
  tests = relationship("Test", backref="run", lazy="subquery", cascade="all, delete", passive_deletes=True)
@@ -63,6 +73,10 @@ class Run(Base):
63
73
  class ActiveFlakeFighter(Base):
64
74
  """
65
75
  Store relevant information about the active flakefighters.
76
+
77
+ :ivar run_id: Foreign key of the related run.
78
+ :ivar name: Class name of the flakefighter.
79
+ :ivar params: The parameterss of the flakefighter.
66
80
  """
67
81
 
68
82
  run_id: Mapped[int] = Column(Integer, ForeignKey("run.id"), nullable=False)
@@ -74,6 +88,17 @@ class ActiveFlakeFighter(Base):
74
88
  class Test(Base):
75
89
  """
76
90
  Class to store attributes of a test case.
91
+
92
+ :ivar run_id: Foreign key of the related run.
93
+ :ivar fspath: File system path of the file containing the test definition.
94
+ :ivar line_no: Line number of the test definition.
95
+ :ivar name: Name of the test case.
96
+ :ivar skipped: Boolean true if the test was skipped, else false.
97
+ :ivar executions: List of execution attempts.
98
+ :ivar flakefighter_results: List of test-level flakefighter results.
99
+
100
+ .. note::
101
+ Execution-level flakefighter results will be stored inside the individual TestExecution objects
77
102
  """
78
103
 
79
104
  run_id: Mapped[int] = Column(Integer, ForeignKey("run.id"), nullable=False)
@@ -104,6 +129,16 @@ class Test(Base):
104
129
  class TestExecution(Base): # pylint: disable=R0902
105
130
  """
106
131
  Class to store attributes of a test outcome.
132
+
133
+ :ivar test_id: Foreign key of the related test.
134
+ :ivar outcome: Outcome of the test. One of "passed", "failed", or "skipped".
135
+ :ivar stdout: The captured stdout string.
136
+ :ivar stedrr: The captured stderr string.
137
+ :ivar start_time: The start time of the test.
138
+ :ivar end_time: The end time of the test.
139
+ :ivar coverage: The line coverage of the test.
140
+ :ivar flakefighter_results: The execution-level flakefighter results.
141
+ :ivar exception: The exception associated with the test if one was thrown.
107
142
  """
108
143
 
109
144
  __tablename__ = "test_execution"
@@ -140,6 +175,10 @@ class TestExecution(Base): # pylint: disable=R0902
140
175
  class TestException(Base): # pylint: disable=R0902
141
176
  """
142
177
  Class to store information about the exceptions that cause tests to fail.
178
+
179
+ :ivar execution_id: Foreign key of the related execution.
180
+ :ivar name: Name of the exception.
181
+ :traceback: The full stack of traceback entries.
143
182
  """
144
183
 
145
184
  __tablename__ = "test_exception"
@@ -155,6 +194,13 @@ class TestException(Base): # pylint: disable=R0902
155
194
  class TracebackEntry(Base): # pylint: disable=R0902
156
195
  """
157
196
  Class to store attributes of entries in the stack trace.
197
+
198
+ :ivar exception_id: Foreign key of the related exception.
199
+ :ivar path: Filepath of the source file.
200
+ :ivar lineno: Line number of the executed statement.
201
+ :ivar colno: Column number of the executed statement.
202
+ :ivar statement: The executed statement.
203
+ :ivar source: The surrounding source code.
158
204
  """
159
205
 
160
206
  exception_id: Mapped[int] = Column(Integer, ForeignKey("test_exception.id"), nullable=False)
@@ -169,6 +215,11 @@ class TracebackEntry(Base): # pylint: disable=R0902
169
215
  class FlakefighterResult(Base): # pylint: disable=R0902
170
216
  """
171
217
  Class to store flakefighter results.
218
+
219
+ :ivar test_execution_id: Foreign key of the related test execution. Should not be set if test_id is present.
220
+ :ivar test_id: Foreign key of the related test. Should not be set if test_execution_id is present.
221
+ :ivar name: Name of the flakefighter.
222
+ :ivar flaky: Boolean true if the test (execution) was classified as flaky.
172
223
  """
173
224
 
174
225
  __tablename__ = "flakefighter_result"
@@ -182,10 +233,25 @@ class FlakefighterResult(Base): # pylint: disable=R0902
182
233
  CheckConstraint("NOT (test_execution_id IS NULL AND test_id IS NULL)", name="check_test_id_not_null"),
183
234
  )
184
235
 
236
+ @property
237
+ def classification(self):
238
+ """
239
+ Return the classification as a string.
240
+ "flaky" if the test was classified as flaky, else "genuine".
241
+ """
242
+ return "flaky" if self.flaky else "genuine"
243
+
185
244
 
186
245
  class Database:
187
246
  """
188
247
  Class to handle database setup and interaction.
248
+
249
+ :ivar engine: The database engine.
250
+ :ivar store_max_runs: The maximum number of previous runs that should be stored. If the database exceeds this size,
251
+ older runs will be pruned to make space for newer ones.
252
+ :ivar time_immemorial: Time before which runs should not be considered. Runs before this date will be pruned when
253
+ saving new runs.
254
+ :ivar previous_runs: List of previous flakefighter runs with most recent first.
189
255
  """
190
256
 
191
257
  def __init__(
@@ -230,4 +296,4 @@ class Database:
230
296
  :param limit: The maximum number of runs to return (these will be most recent runs).
231
297
  """
232
298
  with Session(self.engine) as session:
233
- return session.scalars(select(Run).order_by(desc(Run.id)).limit(limit)).all()
299
+ return session.scalars(select(Run).order_by(desc(Run.start_time)).limit(limit)).all()
@@ -68,7 +68,10 @@ class DeFlaker(FlakeFighter):
68
68
  self.method_declarations = {}
69
69
  for file, lines in self.lines_changed.items():
70
70
  with open(file) as f:
71
- tree = ast.parse(f.read())
71
+ try:
72
+ tree = ast.parse(f.read())
73
+ except SyntaxError:
74
+ continue
72
75
 
73
76
  self.method_declarations[file] = [
74
77
  node.lineno
@@ -133,9 +136,5 @@ class DeFlaker(FlakeFighter):
133
136
  :param run: Run object representing the pytest run, with tests accessible through run.tests.
134
137
  """
135
138
  for test in run.tests:
136
- test.flakefighter_results.append(
137
- FlakefighterResult(
138
- name=self.__class__.__name__,
139
- flaky=any(self._flaky_execution(execution) for execution in test.executions),
140
- )
141
- )
139
+ for execution in test.executions:
140
+ self.flaky_test_live(execution)
@@ -49,7 +49,7 @@ class TracebackMatching(FlakeFighter):
49
49
  Convert the key parameters into a dictionary so that the object can be replicated.
50
50
  :return A dictionary of the parameters used to create the object.
51
51
  """
52
- return {"root": self.root}
52
+ return {"run_live": self.run_live, "root": self.root}
53
53
 
54
54
  def _flaky_execution(self, execution, previous_executions) -> bool:
55
55
  """
@@ -66,15 +66,13 @@ class TracebackMatching(FlakeFighter):
66
66
  ]
67
67
  return any(e == current_traceback for e in previous_executions)
68
68
 
69
- def previous_flaky_executions(self, runs: list[Run] = None) -> list:
69
+ def previous_flaky_executions(self, runs: list[Run]) -> list:
70
70
  """
71
71
  Extract the relevant information from previous flaky executions and collapse into a single list.
72
72
  :param runs: The runs to consider. Defaults to self.previous_runs.
73
73
  :return: List containing the relative path, line number, column number, and code statement of all previous
74
74
  test executions.
75
75
  """
76
- if runs is None:
77
- runs = self.previous_runs
78
76
  return [
79
77
  [
80
78
  (os.path.relpath(elem.path, run.root), elem.lineno, elem.colno, elem.statement)
@@ -87,17 +85,20 @@ class TracebackMatching(FlakeFighter):
87
85
  if execution.exception
88
86
  ]
89
87
 
90
- def flaky_test_live(self, execution: TestExecution):
88
+ def flaky_test_live(self, execution: TestExecution, previous_runs: list[Run] = None):
91
89
  """
92
90
  Classify executions as flaky if they have the same failure logs as a flaky execution.
93
91
  :param execution: Test execution to consider.
92
+ :param previous_runs: The previous runs to which the execution will be compared.
94
93
  """
94
+ if previous_runs is None:
95
+ previous_runs = self.previous_runs
95
96
  execution.flakefighter_results.append(
96
97
  FlakefighterResult(
97
98
  name=self.__class__.__name__,
98
99
  flaky=self._flaky_execution(
99
100
  execution,
100
- self.previous_flaky_executions(),
101
+ self.previous_flaky_executions(previous_runs),
101
102
  ),
102
103
  )
103
104
  )
@@ -109,14 +110,7 @@ class TracebackMatching(FlakeFighter):
109
110
  """
110
111
  for test in run.tests:
111
112
  for execution in test.executions:
112
- execution.flakefighter_results.append(
113
- FlakefighterResult(
114
- name=self.__class__.__name__,
115
- flaky=self._flaky_execution(
116
- execution, self.previous_flaky_executions(self.previous_runs + [run])
117
- ),
118
- )
119
- )
113
+ self.flaky_test_live(execution, self.previous_runs + [run])
120
114
 
121
115
 
122
116
  class CosineSimilarity(TracebackMatching):
@@ -3,13 +3,13 @@ This module adds all the FlakeFighter configuration options to pytest.
3
3
  """
4
4
 
5
5
  import logging
6
- import os
7
6
  from importlib.metadata import entry_points
8
7
 
9
8
  import coverage
10
9
  import pytest
11
10
  import yaml
12
11
 
12
+ from pytest_flakefighters.config import options
13
13
  from pytest_flakefighters.database_management import Database
14
14
  from pytest_flakefighters.flakefighters.deflaker import DeFlaker
15
15
  from pytest_flakefighters.function_coverage import Profiler
@@ -35,79 +35,45 @@ def pytest_addoption(parser: pytest.Parser):
35
35
  Add extra pytest options.
36
36
  :param parser: The argument parser.
37
37
  """
38
+ # Allows users to specify flakefighter configurations in their pyproject.toml file without pytest throwing out
39
+ # "unknown configuration option" warnings
40
+ parser.addini("pytest_flakefighters", type="args", help="Configuration for the pytest-flakefighters extension")
41
+
42
+ def datatype(details):
43
+ if "type" not in details:
44
+ return None
45
+ if details["type"] is str:
46
+ return "string"
47
+ return str(details["type"].__name__)
48
+
38
49
  group = parser.getgroup("flakefighters")
39
- group.addoption(
40
- "--root",
41
- dest="root",
42
- action="store",
43
- default=os.getcwd(),
44
- help="The root directory of the project. Defaults to the current working directory.",
45
- )
46
- group.addoption(
47
- "--suppress-flaky-failures-exit-code",
48
- dest="suppress_flaky",
49
- action="store_true",
50
- default=False,
51
- help="Return OK exit code if the only failures are flaky failures.",
52
- )
53
- group.addoption(
54
- "--no-save",
55
- action="store_true",
56
- default=False,
57
- help="Do not save this run to the database of previous flakefighters runs.",
58
- )
59
- group.addoption(
60
- "--function-coverage",
61
- action="store_true",
62
- default=False,
63
- help="Use function-level coverage instead of line coverage.",
64
- )
65
- group.addoption(
66
- "--load-max-runs",
67
- "-M",
68
- action="store",
69
- default=None,
70
- help="The maximum number of previous runs to consider.",
71
- )
72
- group.addoption(
73
- "--database-url",
74
- "-D",
75
- action="store",
76
- default="sqlite:///flakefighters.db",
77
- help="The database URL. Defaults to 'flakefighters.db' in current working directory.",
78
- )
79
- group.addoption(
80
- "--store-max-runs",
81
- action="store",
82
- default=None,
83
- type=int,
84
- help="The maximum number of previous flakefighters runs to store. Default is to store all.",
85
- )
86
- group.addoption(
87
- "--max-reruns",
88
- action="store",
89
- default=0,
90
- type=int,
91
- help="The maximum number of times to rerun tests. "
92
- "By default, only failing tests marked as flaky will be rerun. "
93
- "This can be changed with the --rerun-strategy parameter.",
94
- )
95
- group.addoption(
96
- "--rerun-strategy",
97
- action="store",
98
- type=str,
99
- choices=list(rerun_strategies),
100
- default="FLAKY_FAILURE",
101
- help="The strategy used to determine which tests to rerun. Supported options are:\n "
102
- + "\n ".join(f"{name} - {strat.help()}" for name, strat in rerun_strategies.items()),
103
- )
104
- group.addoption(
105
- "--time-immemorial",
106
- action="store",
107
- default=None,
108
- help="How long to store flakefighters runs for, specified as `days:hours:minutes`. "
109
- "E.g. to store tests for one week, use 7:0:0.",
110
- )
50
+ for name, details in options.items():
51
+ # Add a commandline option with short name if provided, e.g. "--custom-option"
52
+ # We need the default to be None here so that we can test if the user has provided it
53
+ group.addoption(*name, **(details | {"default": None}))
54
+ # Add configuration file option with no "--" and "-" replaced by "_"
55
+ parser.addini(
56
+ name[0][2:].replace("-", "_"),
57
+ help=details["help"],
58
+ default=details.get("default"),
59
+ type=datatype(details),
60
+ )
61
+
62
+
63
+ def get_config_value(config, name):
64
+ """
65
+ Get the configuration value.
66
+ Options specified on the commandline will override those specified in configuration files.
67
+ If neither is specified, the default value specified in `options.py` will be used.
68
+ """
69
+ cli_val = config.getoption(name)
70
+ if cli_val is not None:
71
+ return cli_val
72
+
73
+ try:
74
+ return config.getini(name)
75
+ except ValueError:
76
+ return None
111
77
 
112
78
 
113
79
  def pytest_configure(config: pytest.Config):
@@ -116,52 +82,66 @@ def pytest_configure(config: pytest.Config):
116
82
  :param config: The config options.
117
83
  """
118
84
  database = Database(
119
- config.option.database_url,
120
- config.option.load_max_runs,
121
- config.option.store_max_runs,
122
- config.option.time_immemorial,
85
+ get_config_value(config, "database_url"),
86
+ get_config_value(config, "load_max_runs"),
87
+ get_config_value(config, "store_max_runs"),
88
+ get_config_value(config, "time_immemorial"),
123
89
  )
124
90
 
125
- if config.option.function_coverage:
91
+ if get_config_value(config, "function_coverage"):
126
92
  cov = Profiler()
127
93
  else:
128
94
  cov = coverage.Coverage()
129
95
 
130
- algorithms = entry_points(group="pytest_flakefighters")
96
+ algorithms = {ff.name: ff for ff in entry_points(group="pytest_flakefighters")}
131
97
  flakefighter_configs = config.inicfg.get("pytest_flakefighters")
132
98
 
133
99
  flakefighters = []
134
100
  if flakefighter_configs is not None:
135
- if isinstance(flakefighter_configs, str):
136
- flakefighter_configs = yaml.safe_load(flakefighter_configs)
137
- elif hasattr(flakefighter_configs, "value"):
138
- flakefighter_configs = yaml.safe_load(flakefighter_configs.value)
139
- else:
140
- raise TypeError(f"Unexpected type for config: {type(flakefighter_configs)}")
141
- for flakefighter in algorithms:
142
- if flakefighter.name in flakefighter_configs:
143
- flakefighters.append(
144
- flakefighter.load().from_config(
145
- vars(config.option) | {"database": database} | flakefighter_configs[flakefighter.name]
101
+ # Can't measure coverage since the branch taken depends on the python version
102
+ if isinstance(flakefighter_configs, str): # pragma: no cover
103
+ flakefighter_configs = yaml.safe_load(flakefighter_configs) # pragma: no cover
104
+ elif hasattr(flakefighter_configs, "value"): # pragma: no cover
105
+ flakefighter_configs = yaml.safe_load(flakefighter_configs.value) # pragma: no cover
106
+ else: # pragma: no cover
107
+ raise TypeError(f"Unexpected type for config: {type(flakefighter_configs)}") # pragma: no cover
108
+ for module, classes in flakefighter_configs["flakefighters"].items():
109
+ for class_name, params in classes.items():
110
+ if class_name in algorithms:
111
+ flakefighters.append(
112
+ algorithms[class_name]
113
+ .load()
114
+ .from_config(
115
+ {k: get_config_value(config, k) for k in vars(config.option)}
116
+ | {"database": database}
117
+ | params
118
+ )
119
+ )
120
+ else:
121
+ raise ValueError(
122
+ f"Could not load flakefighter {module}:{class_name}. Did you register its entry point?"
146
123
  )
147
- )
148
124
 
149
125
  else:
150
126
  logger.warning("No flakefighters specified. Using basic DeFlaker only.")
151
127
  flakefighters.append(
152
128
  DeFlaker(
153
129
  run_live=True,
154
- root=config.option.root,
130
+ root=get_config_value(config, "root"),
155
131
  )
156
132
  )
157
133
 
158
134
  config.pluginmanager.register(
159
135
  FlakeFighterPlugin(
160
- root=config.option.root,
136
+ root=get_config_value(config, "root"),
161
137
  database=database,
162
138
  cov=cov,
163
139
  flakefighters=flakefighters,
164
- rerun_strategy=rerun_strategy(config.option.rerun_strategy, config.option.max_reruns, database=database),
165
- save_run=not config.option.no_save,
140
+ rerun_strategy=rerun_strategy(
141
+ get_config_value(config, "rerun_strategy"), get_config_value(config, "max_reruns"), database=database
142
+ ),
143
+ save_run=not get_config_value(config, "no_save"),
144
+ display_outcomes=get_config_value(config, "display_outcomes"),
145
+ display_verdicts=get_config_value(config, "display_verdicts"),
166
146
  )
167
147
  )
@@ -5,6 +5,7 @@ This module implements the DeFlaker algorithm [Bell et al. 10.1145/3180155.31801
5
5
  from datetime import datetime
6
6
  from enum import Enum
7
7
  from typing import Union
8
+ from xml.etree import ElementTree as ET
8
9
 
9
10
  import coverage
10
11
  import pytest
@@ -49,6 +50,8 @@ class FlakeFighterPlugin: # pylint: disable=R0902
49
50
  flakefighters: list[FlakeFighter],
50
51
  save_run: bool = True,
51
52
  rerun_strategy: RerunStrategy = RerunStrategy.FLAKY_FAILURE,
53
+ display_outcomes: int = 0,
54
+ display_verdicts: bool = False,
52
55
  ):
53
56
  self.root = root
54
57
  self.database = database
@@ -56,12 +59,16 @@ class FlakeFighterPlugin: # pylint: disable=R0902
56
59
  self.flakefighters = flakefighters
57
60
  self.save_run = save_run
58
61
  self.rerun_strategy = rerun_strategy
62
+ self.test_reports = {}
63
+ self.display_verdicts = display_verdicts
64
+ self.display_outcomes = display_outcomes
59
65
 
60
66
  self.run = Run( # pylint: disable=E1123
61
67
  root=root,
62
68
  active_flakefighters=[
63
69
  ActiveFlakeFighter(name=f.__class__.__name__, params=f.params()) for f in flakefighters
64
70
  ],
71
+ start_time=datetime.now(),
65
72
  )
66
73
 
67
74
  def pytest_sessionstart(self, session: pytest.Session): # pylint: disable=unused-argument
@@ -176,9 +183,54 @@ class FlakeFighterPlugin: # pylint: disable=R0902
176
183
  test.executions.append(test_execution)
177
184
  for ff in filter(lambda ff: ff.run_live, self.flakefighters):
178
185
  ff.flaky_test_live(test_execution)
186
+ self.test_reports[item.nodeid] = report
179
187
  report.flaky = any(result.flaky for result in test_execution.flakefighter_results)
188
+ # Limited pytest-json support
189
+ report.stage_metadata = {
190
+ "executions": [
191
+ {
192
+ "start_time": x.start_time.isoformat(),
193
+ "end_time": x.end_time.isoformat(),
194
+ "outcome": test_execution.outcome,
195
+ "flakefighter_results": {r.name: r.classification for r in x.flakefighter_results},
196
+ }
197
+ for x in test.executions
198
+ ],
199
+ }
200
+ # html
201
+ if hasattr(report, "extras"):
202
+ report.extras.append(
203
+ {
204
+ "content": f"""
205
+ <h4>Flakefighter Results</h4>
206
+ <div id="ff-{report.nodeid.replace("::", "_")}"></div>
207
+ <table style="width:100%"><tbody><tr>"""
208
+ + "".join(
209
+ [
210
+ f"""
211
+ <td>
212
+ <p><strong>Start time:</strong> {execution.start_time}</p>
213
+ <p><strong>End time:</strong> {execution.end_time}</p>
214
+ <p><strong>Outcome:</strong> {execution.outcome}</p>
215
+ <p><strong>Flakefighter Results:</strong></p>
216
+ <ul>
217
+ {''.join(['<li><strong>'+result.name+':</strong> '+result.classification+'</li>'
218
+ for result in execution.flakefighter_results])}
219
+ </ul>
220
+ </td>
221
+ """
222
+ for execution in test.executions
223
+ ]
224
+ )
225
+ + "</tr></tbody></table>",
226
+ "extension": "html",
227
+ "format_type": "html",
228
+ "mime_type": "text/html",
229
+ }
230
+ )
180
231
  if item.execution_count <= self.rerun_strategy.max_reruns and self.rerun_strategy.rerun(report):
181
232
  break # trigger rerun
233
+
182
234
  item.ihook.pytest_runtest_logreport(report=report)
183
235
  else:
184
236
  break # Skip further reruns
@@ -199,6 +251,128 @@ class FlakeFighterPlugin: # pylint: disable=R0902
199
251
  return report.outcome, "F", ("FLAKY", {"yellow": True})
200
252
  return None
201
253
 
254
+ @pytest.hookimpl(hookwrapper=True)
255
+ def pytest_runtestloop(self, session: pytest.Session): # pylint: disable=unused-argument
256
+ """
257
+ Run postprocessing flakefighters.
258
+ :param session: The pytest session object.
259
+ """
260
+ yield
261
+ for ff in filter(lambda ff: not ff.run_live, self.flakefighters):
262
+ ff.flaky_tests_post(self.run)
263
+ for test in self.run.tests:
264
+ if test.name in self.test_reports:
265
+ self.test_reports[test.name].flakefighter_results = {
266
+ r.name: r.classification for r in test.flakefighter_results
267
+ }
268
+
269
+ @pytest.hookimpl(optionalhook=True)
270
+ def pytest_json_modifyreport(self, json_report: dict):
271
+ """
272
+ Add flakefighter results to the pytest-json-report report.
273
+
274
+ :param json_report: The report dict.
275
+ """
276
+ for t in json_report.get("tests", []):
277
+ t["call"]["metadata"] = self.test_reports[t["nodeid"]].stage_metadata
278
+
279
+ t["metadata"] = t.get("metadata", {}) | {
280
+ "flakefighter_results": self.test_reports[t["nodeid"]].flakefighter_results
281
+ }
282
+
283
+ def build_outcome_string(self, test: Test) -> str:
284
+ """
285
+ Construct a string to represent previous flakefighter outcomes for a given test and its associated executions.
286
+
287
+ :param test: The test case.
288
+ """
289
+ result_string = []
290
+ if test.flakefighter_results:
291
+ if self.display_verdicts:
292
+ result_string.append(
293
+ "Overall\n" + "\n".join(f" {f.name}: {f.classification}" for f in test.flakefighter_results) + "\n"
294
+ )
295
+ for i, execution in enumerate(test.executions):
296
+ if execution.flakefighter_results:
297
+ if self.display_verdicts:
298
+ result_string.append(
299
+ f"Execution {i}: {execution.outcome}\n"
300
+ + "\n".join(
301
+ f" {f.name}: {'flaky' if f.flaky else 'genuine'}" for f in execution.flakefighter_results
302
+ )
303
+ )
304
+ else:
305
+ result_string.append(f"Execution {i}: {execution.outcome}")
306
+ return "\n".join(result_string)
307
+
308
+ def modify_xml(self, xml_path: str):
309
+ """
310
+ Modify the JUnitXML file to add the flakefighter results for each test.
311
+
312
+ :param xml_path: The path of the saved XML file.
313
+ """
314
+ tree = ET.parse(xml_path)
315
+ for testsuite in tree.getroot().findall("testsuite"):
316
+ for testcase in testsuite.findall("testcase"):
317
+ splitname = testcase.get("classname").split(".")
318
+ splitname[0] = f"{splitname[0]}.py"
319
+ nodeid = "::".join(splitname + [testcase.get("name")])
320
+ flakefighter_results = ET.SubElement(testcase, "flakefighterresults")
321
+ if nodeid in self.test_reports:
322
+ for execution in self.test_reports[nodeid].stage_metadata["executions"]:
323
+ execution_results = ET.Element(
324
+ "execution",
325
+ {
326
+ "outcome": execution["outcome"],
327
+ "starttime": execution["start_time"],
328
+ "endtime": execution["end_time"],
329
+ },
330
+ )
331
+ flakefighter_results.append(execution_results)
332
+ for name, classification in execution["flakefighter_results"].items():
333
+ ET.SubElement(execution_results, name).text = classification
334
+ test_results = ET.SubElement(flakefighter_results, "test")
335
+ for name, classification in self.test_reports[nodeid].flakefighter_results.items():
336
+ ET.SubElement(test_results, name).text = classification
337
+
338
+ tree.write(xml_path)
339
+
340
+ @pytest.hookimpl(optionalhook=True)
341
+ def pytest_html_results_summary(
342
+ self, prefix: list, summary: list, postfix: list
343
+ ): # pylint: disable=unused-argument
344
+ """
345
+ Add the test-level flakefighter results.
346
+ :param prefix: The prefix content. UNUSED.
347
+ :param summary: The summary content. UNUSED.
348
+ :param postfix: The postfix content.
349
+ """
350
+ postfix.extend(
351
+ [
352
+ "<h2>Test-level flakefighter results</h2>",
353
+ "<table>",
354
+ "<thead><tr><td>Test</td><td>Flakefighter results</td></tr></thead>",
355
+ "<tbody>",
356
+ ]
357
+ + [
358
+ f"<tr><td>{nodeid}</td><td>"
359
+ + "".join(
360
+ [
361
+ f"""<ul>
362
+ {''.join(['<li><strong>'+result['name']+':</strong> '+result['classification']+'</li>'
363
+ for result in report.flakefighter_results])}
364
+ </ul>"""
365
+ ]
366
+ )
367
+ + "</td></tr>"
368
+ for nodeid, report in self.test_reports.items()
369
+ ]
370
+ + [
371
+ "</tbody>",
372
+ "</table>",
373
+ ]
374
+ )
375
+
202
376
  def pytest_sessionfinish(
203
377
  self,
204
378
  session: pytest.Session,
@@ -208,8 +382,23 @@ class FlakeFighterPlugin: # pylint: disable=R0902
208
382
  :param session: The pytest session object.
209
383
  :param exitstatus: The status which pytest will return to the system.
210
384
  """
211
- for ff in filter(lambda ff: not ff.run_live, self.flakefighters):
212
- ff.flaky_tests_post(self.run)
385
+
386
+ if session.config.option.xmlpath:
387
+ self.modify_xml(session.config.option.xmlpath)
388
+
389
+ runs = [self.run]
390
+ if self.display_outcomes:
391
+ runs += self.database.load_runs(self.display_outcomes)
392
+ if self.display_verdicts or self.display_outcomes:
393
+ for run in runs:
394
+ for test in run.tests:
395
+ if test.name in self.test_reports:
396
+ self.test_reports[test.name].sections.append(
397
+ (
398
+ f"Flakefighter Verdicts {run.start_time if run != self.run else '(Current)'}",
399
+ self.build_outcome_string(test),
400
+ )
401
+ )
213
402
 
214
403
  genuine_failure_observed = any(
215
404
  not test.flaky for test in self.run.tests if any(e.outcome != "passed" for e in test.executions)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-flakefighters
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Pytest plugin implementing flaky test failure detection and classification.
5
5
  Author: TestFLARE Team
6
6
  Project-URL: Documentation, https://pytest-flakefighters.readthedocs.io
@@ -38,22 +38,26 @@ Requires-Dist: psycopg2>2.9; extra == "pg"
38
38
  Dynamic: license-file
39
39
 
40
40
  # Pytest FlakeFighters
41
- ### Pytest plugin implementing flaky test failure detection and classification.
42
41
 
43
42
  [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active)
44
43
  [![PyPI version](https://img.shields.io/pypi/v/pytest-flakefighters.svg)](https://pypi.org/project/pytest-flakefighters)
45
44
  [![Python versions](https://img.shields.io/badge/python-3.10_--_3.13-blue)](https://pypi.org/project/pytest-flakefighters)
46
45
  ![Test status](https://github.com/test-flare/pytest-flakefighters/actions/workflows/ci-tests.yaml/badge.svg)
47
46
  [![codecov](https://codecov.io/gh/test-flare/pytest-flakefighters/branch/main/graph/badge.svg?token=04ijFVrb4a)](https://codecov.io/gh/test-flare/pytest-flakefighters)
48
- [![Documentation Status](https://readthedocs.org/projects/causal-testing-framework/badge/?version=latest)](https://causal-testing-framework.readthedocs.io/en/latest/?badge=latest)
47
+ [![Documentation Status](https://readthedocs.org/projects/pytest-flakefighters/badge/?version=latest)](https://pytest-flakefighters.readthedocs.io/en/latest/?badge=latest)
49
48
  ![GitHub License](https://img.shields.io/github/license/test-flare/pytest-flakefighters)
50
49
 
51
-
50
+ ### Pytest plugin implementing flaky test failure detection and classification.
51
+ Read more about flaky tests [here](https://docs.pytest.org/en/stable/explanation/flaky.html).
52
52
 
53
53
  ## Features
54
54
 
55
- - Implements the [DeFlaker algorithm](https://deflaker.com/) for pytest
56
-
55
+ - Implements the [DeFlaker algorithm](http://www.deflaker.org/get-rid-of-your-flakes/) for pytest
56
+ - Implements two traceback-matching classifiers from [Alshammari et al. (2024)](https://doi.org/10.1109/ICST60714.2024.00031).
57
+ - Implements a novel coverage-independence classifier that classifies tests as flaky if they fail independently of passing test cases that exercise overlapping code.
58
+ - Optionally rerun flaky failures
59
+ - Output results to JSON, HTML, or JUnitXML
60
+ - Save test outcome history to a remote or local database
57
61
 
58
62
  ## Installation
59
63
 
@@ -0,0 +1,19 @@
1
+ _version.py,sha256=gGLpQUQx-ty9SEy9PYw9OgJWWzJLBnCpfJOfzL7SjlI,704
2
+ pytest_flakefighters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pytest_flakefighters/config.py,sha256=NBqbonht0O4L_P3hV_g__LiD6Fo5aGIau7D84hBA5kE,3460
4
+ pytest_flakefighters/database_management.py,sha256=FZTDYG9uVlPY2j874I_YDFQ9kuNXhm4P9fL3ckrg8MY,10427
5
+ pytest_flakefighters/function_coverage.py,sha256=IgzWHMFgkVrc68ZjQ1o27tGxKa8WvcMcGW8RWnpFLVw,2187
6
+ pytest_flakefighters/main.py,sha256=QxhaCdrbJmMNgekUDRsLIwNDhnd_vaGQ56wpbYKcV1U,5541
7
+ pytest_flakefighters/plugin.py,sha256=enB2bXXE76Swpn7aHrb2j8fx2oCHi2bcQAx9kLWrTOc,17489
8
+ pytest_flakefighters/rerun_strategies.py,sha256=YI-spvm0dhB-gGM9IEgDz19NHvzD_XesZ8lDcBDGSJM,2536
9
+ pytest_flakefighters/flakefighters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pytest_flakefighters/flakefighters/abstract_flakefighter.py,sha256=bchRwoNzQaFhSknT9wKDpbf3EiReHPsHsDhm5qi8y_o,1953
11
+ pytest_flakefighters/flakefighters/coverage_independence.py,sha256=5swoY1jOaaGFlZNX6PdejBFVd7Gpi3zsaW5W6toIzk8,4480
12
+ pytest_flakefighters/flakefighters/deflaker.py,sha256=eqqfhvKrxYIbbtm_pdqTw2AwmcHo5vBh3HfqyscusdU,5911
13
+ pytest_flakefighters/flakefighters/traceback_matching.py,sha256=L-3vVdB9aTjGHrsCAh9R8L51YQoSfRUfhMioIywoJqM,6808
14
+ pytest_flakefighters-0.3.1.dist-info/licenses/LICENSE,sha256=tTzR2CWQMPOp-mQIQqi0cTRkaogeBUmW06blQsBLdQg,1082
15
+ pytest_flakefighters-0.3.1.dist-info/METADATA,sha256=qJ0VwrCDLgYhWnZASbHn17hivEt1XJVeszRfB49Ko6I,6044
16
+ pytest_flakefighters-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
+ pytest_flakefighters-0.3.1.dist-info/entry_points.txt,sha256=xockvN1AszN2XqaET77JDIRdOafgm3DdvOtHgVw-aDU,424
18
+ pytest_flakefighters-0.3.1.dist-info/top_level.txt,sha256=bWwe0xVZ_l2CP8KSnztW6FafZKQZ7zUTMa1U32geY28,30
19
+ pytest_flakefighters-0.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,18 +0,0 @@
1
- _version.py,sha256=kBRz0P2plw1eVdIpt70W6m1LMbEIhLY3RyOfVGdubaI,704
2
- pytest_flakefighters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- pytest_flakefighters/database_management.py,sha256=mSdrP4enD6_oT941y5KAP-Jae84FihVJEvqCUFPhrss,7105
4
- pytest_flakefighters/function_coverage.py,sha256=IgzWHMFgkVrc68ZjQ1o27tGxKa8WvcMcGW8RWnpFLVw,2187
5
- pytest_flakefighters/main.py,sha256=N0yGSGA-ZLtsKRCtSCIdnw7RPVEtLnXUhnftQGHjujA,5430
6
- pytest_flakefighters/plugin.py,sha256=kdagObAK93ab6-5lckwkBR846RPspxXen4qlEcsAjB8,8866
7
- pytest_flakefighters/rerun_strategies.py,sha256=YI-spvm0dhB-gGM9IEgDz19NHvzD_XesZ8lDcBDGSJM,2536
8
- pytest_flakefighters/flakefighters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- pytest_flakefighters/flakefighters/abstract_flakefighter.py,sha256=bchRwoNzQaFhSknT9wKDpbf3EiReHPsHsDhm5qi8y_o,1953
10
- pytest_flakefighters/flakefighters/coverage_independence.py,sha256=5swoY1jOaaGFlZNX6PdejBFVd7Gpi3zsaW5W6toIzk8,4480
11
- pytest_flakefighters/flakefighters/deflaker.py,sha256=GjmAA5i7YFJKbNaheWihqUqyu9_z2NZwqy6U--ztj9c,5989
12
- pytest_flakefighters/flakefighters/traceback_matching.py,sha256=LheOEqCvrBDrZJm4RK4ZFFcebWs4ggP7qRiUPg8HQpM,6926
13
- pytest_flakefighters-0.2.3.dist-info/licenses/LICENSE,sha256=tTzR2CWQMPOp-mQIQqi0cTRkaogeBUmW06blQsBLdQg,1082
14
- pytest_flakefighters-0.2.3.dist-info/METADATA,sha256=k4LadBPIqRWtTIkjOtj1R_KQE8_zUh7ldLPvcETfvOI,5507
15
- pytest_flakefighters-0.2.3.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
16
- pytest_flakefighters-0.2.3.dist-info/entry_points.txt,sha256=xockvN1AszN2XqaET77JDIRdOafgm3DdvOtHgVw-aDU,424
17
- pytest_flakefighters-0.2.3.dist-info/top_level.txt,sha256=bWwe0xVZ_l2CP8KSnztW6FafZKQZ7zUTMa1U32geY28,30
18
- pytest_flakefighters-0.2.3.dist-info/RECORD,,