pytest-testmon 2.1.4__tar.gz → 2.3.0b0__tar.gz

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.
Files changed (21) hide show
  1. {pytest_testmon-2.1.4/pytest_testmon.egg-info → pytest_testmon-2.3.0b0}/PKG-INFO +5 -3
  2. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pyproject.toml +23 -29
  3. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0/pytest_testmon.egg-info}/PKG-INFO +5 -3
  4. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/requires.txt +1 -1
  5. pytest_testmon-2.3.0b0/testmon/__init__.py +3 -0
  6. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/configure.py +4 -2
  7. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/db.py +47 -37
  8. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/process_code.py +16 -8
  9. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/pytest_testmon.py +102 -40
  10. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/testmon_core.py +159 -90
  11. pytest_testmon-2.1.4/testmon/__init__.py +0 -2
  12. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/LICENSE +0 -0
  13. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/MANIFEST.in +0 -0
  14. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/README.md +0 -0
  15. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/SOURCES.txt +0 -0
  16. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/dependency_links.txt +0 -0
  17. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/entry_points.txt +0 -0
  18. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/top_level.txt +0 -0
  19. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/setup.cfg +0 -0
  20. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/setup.py +0 -0
  21. {pytest_testmon-2.1.4 → pytest_testmon-2.3.0b0}/testmon/common.py +0 -0
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-testmon
3
- Version: 2.1.4
3
+ Version: 2.3.0b0
4
4
  Summary: selects tests affected by changed files and methods
5
5
  Author-email: Tibor Arpas <tibor@testmon.org>
6
6
  License: MIT
7
+ Project-URL: Homepage, https://testmon.org
8
+ Project-URL: Source, https://github.com/tarpas/pytest-testmon
7
9
  Keywords: testing,pytest,plugin
8
10
  Classifier: Development Status :: 4 - Beta
9
11
  Classifier: Intended Audience :: Developers
@@ -15,10 +17,10 @@ Classifier: Topic :: Software Development :: Libraries
15
17
  Classifier: Topic :: Utilities
16
18
  Classifier: Programming Language :: Python
17
19
  Classifier: Framework :: Pytest
18
- Requires-Python: >=3.8
20
+ Requires-Python: >=3.11
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
21
- Requires-Dist: pytest<9,>=5
23
+ Requires-Dist: pytest<10,>=5
22
24
  Requires-Dist: coverage<8,>=6
23
25
  Dynamic: license-file
24
26
 
@@ -7,11 +7,11 @@ name = "pytest-testmon"
7
7
  authors = [
8
8
  { name = "Tibor Arpas", email = "tibor@testmon.org" },
9
9
  ]
10
- dynamic = ["version"]
10
+ dynamic = ["version"]
11
11
 
12
12
  description = "selects tests affected by changed files and methods"
13
13
  readme = "README.md"
14
- requires-python = ">=3.8"
14
+ requires-python = ">=3.11"
15
15
  keywords = ["testing", "pytest", "plugin"]
16
16
  license = { text = "MIT" }
17
17
  classifiers = [
@@ -27,38 +27,32 @@ classifiers = [
27
27
  "Framework :: Pytest",
28
28
  ]
29
29
  dependencies = [
30
- "pytest>=5,<9",
30
+ "pytest>=5,<10",
31
31
  "coverage>=6,<8",
32
32
  ]
33
33
 
34
- # specify testmon and tmserver as packages of this project
34
+ [project.urls]
35
+ Homepage = "https://testmon.org"
36
+ Source = "https://github.com/tarpas/pytest-testmon"
37
+
38
+ [project.entry-points.pytest11]
39
+ pytest-testmon = "testmon.pytest_testmon"
40
+
35
41
  [tool.setuptools]
36
- packages = [
37
- "testmon",
38
- ]
42
+ packages = ["testmon"]
39
43
 
40
44
  [tool.setuptools.dynamic]
41
45
  version = {attr = "testmon.TESTMON_VERSION"}
42
46
 
43
- [tool.black]
44
- exclude = '''
45
- (
46
- /(
47
- \.eggs # exclude a few common directories in the
48
- | \.git # root of the project
49
- | \.hg
50
- | \.mypy_cache
51
- | \.tox
52
- | \.venv
53
- | _build
54
- | buck-out
55
- | build
56
- | dist
57
- )/
58
- | io.intellij/
59
- )
60
- '''
61
-
62
-
63
- [project.entry-points.pytest11]
64
- pytest-testmon = "testmon.pytest_testmon"
47
+ # Development dependencies, managed by uv: `uv sync` then `uv run pytest` / `uv run tox`.
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest==8.3.5",
51
+ "pytest-cov",
52
+ "pytest-xdist",
53
+ "pytest-mock",
54
+ "ruff",
55
+ "tox",
56
+ "tox-gh-actions",
57
+ "pre-commit",
58
+ ]
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-testmon
3
- Version: 2.1.4
3
+ Version: 2.3.0b0
4
4
  Summary: selects tests affected by changed files and methods
5
5
  Author-email: Tibor Arpas <tibor@testmon.org>
6
6
  License: MIT
7
+ Project-URL: Homepage, https://testmon.org
8
+ Project-URL: Source, https://github.com/tarpas/pytest-testmon
7
9
  Keywords: testing,pytest,plugin
8
10
  Classifier: Development Status :: 4 - Beta
9
11
  Classifier: Intended Audience :: Developers
@@ -15,10 +17,10 @@ Classifier: Topic :: Software Development :: Libraries
15
17
  Classifier: Topic :: Utilities
16
18
  Classifier: Programming Language :: Python
17
19
  Classifier: Framework :: Pytest
18
- Requires-Python: >=3.8
20
+ Requires-Python: >=3.11
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
21
- Requires-Dist: pytest<9,>=5
23
+ Requires-Dist: pytest<10,>=5
22
24
  Requires-Dist: coverage<8,>=6
23
25
  Dynamic: license-file
24
26
 
@@ -1,2 +1,2 @@
1
- pytest<9,>=5
1
+ pytest<10,>=5
2
2
  coverage<8,>=6
@@ -0,0 +1,3 @@
1
+ """PYTEST_DONT_REWRITE"""
2
+
3
+ TESTMON_VERSION = "2.3.0b0"
@@ -75,10 +75,12 @@ def _get_noselect_reasons(options):
75
75
  if options["markexpr"]:
76
76
  return ["-m was used"]
77
77
 
78
- if options["lf"]:
78
+ # `--lf` is registered by the cacheprovider plugin, absent with `-p no:cacheprovider`
79
+ if options.get("lf"):
79
80
  return ["--lf was used"]
80
81
 
81
- if any(re.match(r"(.*)\.py::(.*)", opt) for opt in options["file_or_dir"] or []):
82
+ file_or_dir = options.get("file_or_dir") or []
83
+ if any(re.match(r"(.*)\.py::(.*)", opt) for opt in file_or_dir):
82
84
  return ["you selected tests manually"]
83
85
 
84
86
  return []
@@ -5,12 +5,10 @@ import sqlite3
5
5
  from collections import namedtuple
6
6
  from functools import lru_cache
7
7
 
8
- from testmon.process_code import blob_to_checksums, checksums_to_blob
9
-
10
8
  from testmon.common import TestExecutions
9
+ from testmon.process_code import blob_to_checksums, checksums_to_blob
11
10
 
12
-
13
- DATA_VERSION = 13
11
+ DATA_VERSION = 14
14
12
 
15
13
  ChangedFileData = namedtuple(
16
14
  "ChangedFileData", "filename name method_checksums id failed"
@@ -81,12 +79,14 @@ class DB: # pylint: disable=too-many-public-methods
81
79
  def version_compatibility(self):
82
80
  return DATA_VERSION
83
81
 
82
+ def close(self):
83
+ self.con.close()
84
+
84
85
  def __enter__(self):
85
- self.con = self.con.__enter__()
86
86
  return self
87
87
 
88
- def __exit__(self, *args, **kwargs):
89
- self.con.__exit__(*args, **kwargs)
88
+ def __exit__(self, *args):
89
+ self.close()
90
90
 
91
91
  def _test_execution_fk_column(self) -> str:
92
92
  return "environment_id"
@@ -100,9 +100,7 @@ class DB: # pylint: disable=too-many-public-methods
100
100
  "UPDATE file_fp SET mtime=?, fsha=? WHERE id = ?", new_mtimes
101
101
  )
102
102
 
103
- def finish_execution(
104
- self, exec_id, duration=None, select=True
105
- ): # pylint: disable=unused-argument
103
+ def finish_execution(self, exec_id, duration=None, select=True): # pylint: disable=unused-argument
106
104
  self.update_saving_stats(exec_id, select)
107
105
  self.fetch_or_create_file_fp.cache_clear()
108
106
  with self.con as con:
@@ -193,9 +191,7 @@ class DB: # pylint: disable=too-many-public-methods
193
191
  )
194
192
 
195
193
  @lru_cache(1000)
196
- def fetch_or_create_file_fp(
197
- self, filename, fsha, method_checksums
198
- ): # pylint: disable=R0801
194
+ def fetch_or_create_file_fp(self, filename, fsha, method_checksums): # pylint: disable=R0801
199
195
  cursor = self.con.cursor()
200
196
  try:
201
197
  cursor.execute(
@@ -216,9 +212,9 @@ class DB: # pylint: disable=too-many-public-methods
216
212
  FROM
217
213
  file_fp
218
214
  WHERE
219
- filename = ? AND method_checksums = ?
215
+ filename = ? AND fsha = ? AND method_checksums = ?
220
216
  """,
221
- (filename, method_checksums),
217
+ (filename, fsha, method_checksums),
222
218
  ).fetchone()
223
219
 
224
220
  return fingerprint_id
@@ -359,7 +355,7 @@ class DB: # pylint: disable=too-many-public-methods
359
355
  duration FLOAT,
360
356
  failed BIT,
361
357
  forced BIT,
362
- FOREIGN KEY({self._test_execution_fk_column()}) REFERENCES {self._test_execution_fk_table()}(id));
358
+ FOREIGN KEY({self._test_execution_fk_column()}) REFERENCES {self._test_execution_fk_table()}(id) ON DELETE CASCADE);
363
359
  CREATE INDEX test_execution_fk_name ON test_execution ({self._test_execution_fk_column()}, test_name);
364
360
  """
365
361
 
@@ -394,7 +390,7 @@ class DB: # pylint: disable=too-many-public-methods
394
390
  CREATE TABLE test_execution_file_fp (
395
391
  test_execution_id INTEGER,
396
392
  fingerprint_id INTEGER,
397
- FOREIGN KEY(test_execution_id) REFERENCES test_execution(id),
393
+ FOREIGN KEY(test_execution_id) REFERENCES test_execution(id) ON DELETE CASCADE,
398
394
  FOREIGN KEY(fingerprint_id) REFERENCES file_fp(id)
399
395
  );
400
396
  CREATE INDEX test_execution_file_fp_both ON test_execution_file_fp (test_execution_id, fingerprint_id);
@@ -403,7 +399,7 @@ class DB: # pylint: disable=too-many-public-methods
403
399
  suite_execution_id INTEGER,
404
400
  filename TEXT,
405
401
  fsha text,
406
- FOREIGN KEY(suite_execution_id) REFERENCES suite_execution(id)
402
+ FOREIGN KEY(suite_execution_id) REFERENCES suite_execution(id) ON DELETE CASCADE
407
403
  );
408
404
  CREATE UNIQUE INDEX sefch_suite_id_filename_sha ON suite_execution_file_fsha(suite_execution_id, filename, fsha);
409
405
  """
@@ -559,23 +555,24 @@ class DB: # pylint: disable=too-many-public-methods
559
555
  return {"affected": method_misses, "failing": failing_tests}
560
556
 
561
557
  def delete_test_executions(self, test_names, exec_id):
562
- self.con.executemany(
563
- f"""
564
- DELETE
565
- FROM test_execution_file_fp
566
- WHERE test_execution_id IN
567
- (SELECT id FROM test_execution WHERE {self._test_execution_fk_column()} = ? AND test_name = ?)
568
- """,
569
- [(exec_id, test_name) for test_name in test_names],
570
- )
571
- self.con.executemany(
572
- f"""
573
- DELETE
574
- FROM test_execution
575
- WHERE {self._test_execution_fk_column()} = ?
576
- AND test_name = ?""",
577
- [(exec_id, test_name) for test_name in test_names],
578
- )
558
+ with self.con as con:
559
+ con.executemany(
560
+ f"""
561
+ DELETE
562
+ FROM test_execution_file_fp
563
+ WHERE test_execution_id IN
564
+ (SELECT id FROM test_execution WHERE {self._test_execution_fk_column()} = ? AND test_name = ?)
565
+ """,
566
+ [(exec_id, test_name) for test_name in test_names],
567
+ )
568
+ con.executemany(
569
+ f"""
570
+ DELETE
571
+ FROM test_execution
572
+ WHERE {self._test_execution_fk_column()} = ?
573
+ AND test_name = ?""",
574
+ [(exec_id, test_name) for test_name in test_names],
575
+ )
579
576
 
580
577
  def all_test_executions(self, exec_id):
581
578
  return {
@@ -659,7 +656,7 @@ class DB: # pylint: disable=too-many-public-methods
659
656
  ORDER BY id DESC
660
657
  """,
661
658
  (environment_name,),
662
- ).fetchone()
659
+ ).fetchone() # TODO to pick best environment of many with the same name, we would need to fetch all.
663
660
 
664
661
  if environment:
665
662
  environment_id = environment["id"]
@@ -669,7 +666,7 @@ class DB: # pylint: disable=too-many-public-methods
669
666
  )
670
667
  else:
671
668
  packages_changed = False
672
- if not environment or packages_changed:
669
+ if not environment:
673
670
  try:
674
671
  cursor.execute(
675
672
  """
@@ -694,6 +691,19 @@ class DB: # pylint: disable=too-many-public-methods
694
691
  (environment_name,),
695
692
  ).fetchone()
696
693
  environment_id = environment["id"]
694
+ elif packages_changed:
695
+ cursor.execute(
696
+ """
697
+ INSERT INTO environment (environment_name, system_packages, python_version)
698
+ VALUES (?, ?, ?)
699
+ """,
700
+ (environment_name, system_packages, python_version),
701
+ )
702
+ new_environment_id = cursor.lastrowid
703
+ cursor.execute(
704
+ "DELETE FROM environment WHERE id = ?", (environment_id,)
705
+ )
706
+ environment_id = new_environment_id
697
707
 
698
708
  return environment_id, packages_changed
699
709
 
@@ -86,14 +86,17 @@ class Block:
86
86
 
87
87
  @lru_cache(300)
88
88
  def bytes_to_string_and_fsha(byte_stream: bytes) -> Union[str, bytes]:
89
- # Replace \f because of http://bugs.python.org/issue19035
90
- byte_stream = byte_stream.replace(b"\f", b" ")
89
+ # The fsha must equal git's blob sha of the CRLF-normalized bytes, so that it
90
+ # matches the index entry returned by get_files_shas() and no read is needed
91
+ # for files git reports as unmodified. The header length is the byte length,
92
+ # and hashing happens before the \f replacement (git does not touch \f).
91
93
  byte_stream = byte_stream.replace(b"\r\n", b"\n")
92
- byte_string = byte_stream.decode(source_encoding(byte_stream), "replace")
93
- git_header = b"blob %u\0" % len(byte_string)
94
94
  hsh = hashlib.sha1()
95
- hsh.update(git_header)
95
+ hsh.update(b"blob %u\0" % len(byte_stream))
96
96
  hsh.update(byte_stream)
97
+ # Replace \f because of http://bugs.python.org/issue19035
98
+ byte_stream = byte_stream.replace(b"\f", b" ")
99
+ byte_string = byte_stream.decode(source_encoding(byte_stream), "replace")
97
100
  if byte_string and byte_string[-1] != "\n":
98
101
  byte_string += "\n"
99
102
  return byte_string, hsh.hexdigest()
@@ -235,13 +238,18 @@ def read_source_sha(filename: str):
235
238
  def noncached_get_files_shas(directory):
236
239
  all_shas = {}
237
240
  try:
241
+ # Run inside `directory` so the printed paths are relative to it (they
242
+ # are the keys testmon looks up); a pathspec argument would print paths
243
+ # relative to the current working directory instead.
238
244
  result = run(
239
- ["git", "ls-files", "--stage", "-m", directory],
245
+ ["git", "ls-files", "--stage", "-m"],
246
+ cwd=directory or None,
240
247
  capture_output=True,
241
- universal_newlines=True,
248
+ text=True,
249
+ encoding="utf-8",
242
250
  check=True,
243
251
  )
244
- except (FileNotFoundError, CalledProcessError):
252
+ except (FileNotFoundError, NotADirectoryError, CalledProcessError):
245
253
  return all_shas
246
254
 
247
255
  modified_files = set()
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Main module of testmon pytest plugin.
4
4
  """
5
+
5
6
  import time
6
7
  import xmlrpc.client
7
8
  import os
@@ -105,13 +106,18 @@ def pytest_addoption(parser):
105
106
  " code serving different endpoints or Django settings."
106
107
  ),
107
108
  )
108
-
109
+ group.addoption(
110
+ "--testmon-singleprocess",
111
+ action="store_false",
112
+ dest="testmon_singleprocess",
113
+ help="Don't track subprocesses",
114
+ )
109
115
  group.addoption(
110
116
  "--tmnet",
111
117
  action="store_true",
112
118
  dest="tmnet",
113
119
  help=(
114
- "This is used for internal beta. Please don't use. You can go to https://www.testmon.net/ to register."
120
+ "Use tmnet cloud for storage instead of .testmondata file used by --testmon (see https://www.testmon.net)."
115
121
  ),
116
122
  )
117
123
 
@@ -132,6 +138,7 @@ def testmon_options(config):
132
138
  "testmon",
133
139
  "no-testmon",
134
140
  "environment_expression",
141
+ "testmon_singleprocess",
135
142
  ]:
136
143
  if config.getoption(label):
137
144
  result.append(label.replace("testmon_", ""))
@@ -165,24 +172,52 @@ def init_testmon_data(config: Config):
165
172
  )
166
173
  else:
167
174
  tmnet_api_key = os.getenv("TMNET_API_KEY")
168
- elif tmnet_api_key is None:
169
- logger.warning(
170
- "TMNET_API_KEY not set.",
175
+
176
+ if not tmnet_api_key.strip():
177
+ raise ValueError(
178
+ "TMNET_API_KEY is required when using --tmnet. "
179
+ "Please set it in pytest.ini, pyproject.toml, or as an environment variable. "
171
180
  )
181
+
172
182
  rpc_proxy = xmlrpc.client.ServerProxy(
173
183
  url,
174
184
  allow_none=True,
175
- headers=[("x-api-key", tmnet_api_key)],
185
+ headers=[("x-api-key", tmnet_api_key.strip())],
176
186
  )
177
187
 
178
- testmon_data = TestmonData(
179
- rootdir=config.rootdir.strpath,
180
- database=rpc_proxy,
181
- environment=environment,
182
- system_packages=system_packages,
183
- readonly=get_running_as(config) == "worker",
184
- )
185
- testmon_data.determine_stable(bool(rpc_proxy))
188
+ # Check if we're a worker and have exec_id from controller
189
+ running_as = get_running_as(config)
190
+ exec_id = None
191
+ system_packages_change = None
192
+ files_of_interest = None
193
+
194
+ if running_as == "worker" and hasattr(config, "workerinput"):
195
+ # Use exec_id sent from controller
196
+ exec_id = config.workerinput.get("testmon_exec_id")
197
+ system_packages_change = config.workerinput.get(
198
+ "testmon_system_packages_change"
199
+ )
200
+ files_of_interest = config.workerinput.get("testmon_files_of_interest")
201
+
202
+ if running_as == "worker" and exec_id is not None:
203
+ # Initialize for xdist worker run
204
+ testmon_data: TestmonData = TestmonData.for_worker(
205
+ rootdir=str(config.rootpath),
206
+ exec_id=exec_id,
207
+ database=rpc_proxy,
208
+ system_packages_change=system_packages_change,
209
+ files_of_interest=files_of_interest,
210
+ environment=environment,
211
+ )
212
+ else:
213
+ # Initialize for local run (controller or single process)
214
+ testmon_data: TestmonData = TestmonData.for_local_run(
215
+ rootdir=str(config.rootpath),
216
+ database=rpc_proxy,
217
+ environment=environment,
218
+ system_packages=system_packages,
219
+ )
220
+ testmon_data.determine_stable()
186
221
  config.testmon_data = testmon_data
187
222
 
188
223
 
@@ -206,7 +241,7 @@ def register_plugins(config, should_select, should_collect, cov_plugin):
206
241
  config.pluginmanager.register(
207
242
  TestmonCollect(
208
243
  TestmonCollector(
209
- config.rootdir.strpath,
244
+ str(config.rootpath),
210
245
  testmon_labels=testmon_options(config),
211
246
  cov_plugin=cov_plugin,
212
247
  ),
@@ -215,8 +250,8 @@ def register_plugins(config, should_select, should_collect, cov_plugin):
215
250
  ),
216
251
  "TestmonCollect",
217
252
  )
218
- if config.pluginmanager.hasplugin("xdist"):
219
- config.pluginmanager.register(TestmonXdistSync())
253
+ if (should_select or should_collect) and config.pluginmanager.hasplugin("xdist"):
254
+ config.pluginmanager.register(TestmonXdistSync(sync=should_collect))
220
255
 
221
256
 
222
257
  def pytest_configure(config):
@@ -238,6 +273,10 @@ def pytest_configure(config):
238
273
  )
239
274
  config.testmon_config: TmConf = tm_conf
240
275
  if tm_conf.select or tm_conf.collect:
276
+ if config.pluginmanager.has_plugin(
277
+ "TestmonSelect"
278
+ ) or config.pluginmanager.has_plugin("TestmonCollect"):
279
+ pytest.exit("You cannot use two instances of testmon plugin.")
241
280
  try:
242
281
  init_testmon_data(config)
243
282
  register_plugins(config, tm_conf.select, tm_conf.collect, cov_plugin)
@@ -321,7 +360,9 @@ def pytest_unconfigure(config):
321
360
 
322
361
 
323
362
  class TestmonCollect:
324
- def __init__(self, testmon, testmon_data, running_as="single", cov_plugin=None):
363
+ def __init__(
364
+ self, testmon, testmon_data: TestmonData, running_as="single", cov_plugin=None
365
+ ):
325
366
  self.testmon_data: TestmonData = testmon_data
326
367
  self.testmon: TestmonCollector = testmon
327
368
  self._running_as = running_as
@@ -332,9 +373,7 @@ class TestmonCollect:
332
373
  self._sessionstarttime = time.time()
333
374
 
334
375
  @pytest.hookimpl(tryfirst=True, hookwrapper=True)
335
- def pytest_pycollect_makeitem(
336
- self, collector, name, obj
337
- ): # pylint: disable=unused-argument
376
+ def pytest_pycollect_makeitem(self, collector, name, obj): # pylint: disable=unused-argument
338
377
  makeitem_result = yield
339
378
  items = makeitem_result.get_result() or []
340
379
  try:
@@ -345,9 +384,7 @@ class TestmonCollect:
345
384
  pass
346
385
 
347
386
  @pytest.hookimpl(tryfirst=True)
348
- def pytest_collection_modifyitems(
349
- self, session, config, items
350
- ): # pylint: disable=unused-argument
387
+ def pytest_collection_modifyitems(self, session, config, items): # pylint: disable=unused-argument
351
388
  should_sync = not session.testsfailed and self._running_as in (
352
389
  "single",
353
390
  "controller",
@@ -356,9 +393,7 @@ class TestmonCollect:
356
393
  config.testmon_data.sync_db_fs_tests(retain=set(self.raw_test_names))
357
394
 
358
395
  @pytest.hookimpl(hookwrapper=True)
359
- def pytest_runtest_protocol(
360
- self, item, nextitem
361
- ): # pylint: disable=unused-argument
396
+ def pytest_runtest_protocol(self, item, nextitem): # pylint: disable=unused-argument
362
397
  self.testmon.start_testmon(item.nodeid, nextitem.nodeid if nextitem else None)
363
398
  result = yield
364
399
  if result.excinfo and issubclass(result.excinfo[0], BaseException):
@@ -371,7 +406,9 @@ class TestmonCollect:
371
406
  if call.when == "teardown":
372
407
  report = result.get_result()
373
408
  report.nodes_files_lines = self.testmon.get_batch_coverage_data()
374
- result.force_result(report)
409
+ result.force_result(
410
+ report
411
+ ) # under xdist, report is serialized on the worker and sent to the controller
375
412
 
376
413
  @pytest.hookimpl
377
414
  def pytest_runtest_logreport(self, report):
@@ -409,17 +446,44 @@ class TestmonCollect:
409
446
 
410
447
 
411
448
  class TestmonXdistSync:
412
- def __init__(self):
449
+ def __init__(self, sync=True):
413
450
  self.await_nodes = 0
414
-
415
- def pytest_testnodeready(self, node): # pylint: disable=unused-argument
451
+ self.sync = sync
452
+
453
+ def pytest_configure_node(self, node):
454
+ """
455
+ Send exec_id and related data from controller to worker during xdist initialization.
456
+ This avoids each worker having to independently determine the environment and check
457
+ for package changes.
458
+
459
+ Note: This hook is called on the controller side for each worker node.
460
+ The node.config here is the controller's config, not the worker's config.
461
+ """
462
+ # Verify we're on the controller (not a worker)
463
+ # node.config in this hook is the controller's config
464
+ running_as = get_running_as(node.config)
465
+ if running_as != "controller":
466
+ return # Safety check: only run on controller
467
+
468
+ # Count the node here, before it starts: counting it when it reports ready would let a
469
+ # fast worker's collection trigger sync_db_fs_tests while a slow one is still determining
470
+ # stability, which then sees the placeholder rows as stable tests and collects nothing.
416
471
  self.await_nodes += 1
417
472
 
418
- def pytest_xdist_node_collection_finished(
419
- self, node, ids
420
- ): # pylint: disable=invalid-name
473
+ # Only proceed if testmon_data has been initialized and workerinput exists
474
+ if hasattr(node.config, "testmon_data") and hasattr(node, "workerinput"):
475
+ testmon_data: TestmonData = node.config.testmon_data
476
+ node.workerinput["testmon_exec_id"] = testmon_data.exec_id
477
+ node.workerinput["testmon_system_packages_change"] = (
478
+ testmon_data.system_packages_change
479
+ )
480
+ node.workerinput["testmon_files_of_interest"] = (
481
+ testmon_data.files_of_interest
482
+ )
483
+
484
+ def pytest_xdist_node_collection_finished(self, node, ids): # pylint: disable=invalid-name
421
485
  self.await_nodes += -1
422
- if self.await_nodes == 0:
486
+ if self.await_nodes == 0 and self.sync:
423
487
  node.config.testmon_data.sync_db_fs_tests(retain=set(ids))
424
488
 
425
489
 
@@ -455,7 +519,7 @@ def format_time_saved(seconds):
455
519
 
456
520
 
457
521
  class TestmonSelect:
458
- def __init__(self, config, testmon_data):
522
+ def __init__(self, config, testmon_data: TestmonData):
459
523
  self.testmon_data: TestmonData = testmon_data
460
524
  self.config = config
461
525
 
@@ -472,15 +536,13 @@ class TestmonSelect:
472
536
  self._interrupted = False
473
537
 
474
538
  def pytest_ignore_collect(self, collection_path: Path, config):
475
- strpath = cached_relpath(str(collection_path), config.rootdir.strpath)
539
+ strpath = cached_relpath(str(collection_path), str(config.rootpath))
476
540
  if strpath in self.deselected_files and self.config.testmon_config.select:
477
541
  return True
478
542
  return None
479
543
 
480
544
  @pytest.hookimpl(trylast=True)
481
- def pytest_collection_modifyitems(
482
- self, session, config, items
483
- ): # pylint: disable=unused-argument
545
+ def pytest_collection_modifyitems(self, session, config, items): # pylint: disable=unused-argument
484
546
  selected = []
485
547
  deselected = []
486
548
  for item in items:
@@ -6,7 +6,7 @@ import sysconfig
6
6
  import textwrap
7
7
  from functools import lru_cache
8
8
  from collections import defaultdict
9
- from xmlrpc.client import Fault, ProtocolError
9
+ from xmlrpc.client import Fault, ProtocolError, ServerProxy
10
10
  from socket import gaierror
11
11
 
12
12
  from typing import TypeVar
@@ -15,9 +15,9 @@ try:
15
15
  from pytest_cov.plugin import CovPlugin
16
16
  except ImportError:
17
17
  pass
18
-
19
18
  import pytest
20
19
  from coverage import Coverage, CoverageData
20
+ from coverage.exceptions import CoverageException
21
21
 
22
22
  from testmon import db
23
23
  from testmon import TESTMON_VERSION as TM_CLIENT_VERSION
@@ -157,23 +157,14 @@ def collect_mhashes(source_tree, new_changed_file_data):
157
157
  class TestmonData: # pylint: disable=too-many-instance-attributes
158
158
  __test__ = False
159
159
 
160
- def __init__( # pylint: disable=too-many-arguments
160
+ def __init__(
161
161
  self,
162
162
  rootdir,
163
163
  database=None,
164
- environment=None,
165
- system_packages=None,
166
- python_version=None,
167
164
  readonly=False,
168
165
  ):
169
166
  self.rootdir = rootdir
170
- self.environment = environment if environment else "default"
171
167
  self.source_tree = SourceTree(rootdir=self.rootdir)
172
- if system_packages is None:
173
- system_packages = get_system_packages()
174
- system_packages = drop_patch_version(system_packages)
175
- if not python_version:
176
- python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
177
168
 
178
169
  if database:
179
170
  self.db = database # pylint: disable=invalid-name
@@ -182,6 +173,48 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
182
173
  os.path.join(self.rootdir, get_data_file_path()), readonly=readonly
183
174
  ) # pylint: disable=invalid-name
184
175
 
176
+ # Initialize instance variables (will be set by init methods)
177
+ self.environment = None
178
+ self.exec_id = None
179
+ self.system_packages_change = None
180
+ self.files_of_interest = None
181
+ self.all_files = {}
182
+ self.unstable_test_names = None
183
+ self.unstable_files = None
184
+ self.stable_test_names = None
185
+ self.stable_files = None
186
+ self.failing_tests = None
187
+
188
+ @classmethod
189
+ def for_local_run(
190
+ cls,
191
+ rootdir,
192
+ database=None,
193
+ environment=None,
194
+ system_packages=None,
195
+ python_version=None,
196
+ ):
197
+ instance = cls(rootdir, database=database, readonly=False)
198
+ instance._init_for_local_run(environment, system_packages, python_version)
199
+ return instance
200
+
201
+ def _init_for_local_run(
202
+ self,
203
+ environment=None,
204
+ system_packages=None,
205
+ python_version=None,
206
+ ):
207
+ """Internal method to initialize for local run."""
208
+ self.environment = environment if environment else "default"
209
+
210
+ if system_packages is None:
211
+ system_packages = get_system_packages()
212
+ system_packages = drop_patch_version(system_packages)
213
+
214
+ if not python_version:
215
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
216
+
217
+ # Initiate execution (controller or single process)
185
218
  try:
186
219
  result = self.db.initiate_execution(
187
220
  self.environment,
@@ -201,32 +234,72 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
201
234
  ),
202
235
  exc,
203
236
  )
204
- self.db = db.DB(
205
- os.path.join(self.rootdir, get_data_file_path())
206
- ) # pylint: disable=invalid-name
237
+ self.db = db.DB(os.path.join(self.rootdir, get_data_file_path())) # pylint: disable=invalid-name
207
238
  result = self.db.initiate_execution(
208
239
  self.environment, system_packages, python_version, {}
209
240
  )
210
- self.exec_id = result["exec_id"]
211
241
 
242
+ self.exec_id = result["exec_id"]
212
243
  self.system_packages_change = result["packages_changed"]
213
244
  self.files_of_interest = result["filenames"]
214
245
 
215
- self.all_files = {}
216
- self.unstable_test_names = None
217
- self.unstable_files = None
218
- self.stable_test_names = None
219
- self.stable_files = None
220
- self.failing_tests = None
246
+ @classmethod
247
+ def for_worker( # pylint: disable=too-many-arguments
248
+ cls,
249
+ rootdir,
250
+ exec_id,
251
+ database=None,
252
+ system_packages_change=None,
253
+ files_of_interest=None,
254
+ environment=None,
255
+ ):
256
+ instance = cls(rootdir, database=database, readonly=True)
257
+ instance._init_for_worker(
258
+ exec_id, system_packages_change, files_of_interest, environment
259
+ )
260
+ return instance
261
+
262
+ def _init_for_worker(
263
+ self,
264
+ exec_id,
265
+ system_packages_change=None,
266
+ files_of_interest=None,
267
+ environment=None,
268
+ ):
269
+ """Internal method to initialize for worker run."""
270
+ self.exec_id = exec_id
271
+ self.system_packages_change = system_packages_change
272
+ self.files_of_interest = files_of_interest
273
+ if environment:
274
+ self.environment = environment
275
+ elif not hasattr(self, "environment") or self.environment is None:
276
+ self.environment = "default"
221
277
 
222
278
  @property
223
279
  def new_db(self):
224
- return self.db.file_created
280
+ # Only local DB instances have file_created attribute
281
+ if isinstance(self.db, db.DB):
282
+ return self.db.file_created
283
+ return False
225
284
 
226
285
  def close_connection(self):
227
- pass
228
- # TODO this is why there are "too many open files" on MacOS.
229
- # we need the leave the connection open for tests
286
+ """Release the storage: the sqlite connection, or the transport of a tmnet proxy.
287
+
288
+ Idempotent. Tests that inspect the data after a run reopen it (see tests/lib4tests.py).
289
+ """
290
+ database, self.db = self.db, None
291
+ if database is None:
292
+ return
293
+ if isinstance(database, ServerProxy):
294
+ database("close")() # attribute access would be a remote call
295
+ else:
296
+ database.close()
297
+
298
+ def __enter__(self):
299
+ return self
300
+
301
+ def __exit__(self, *args):
302
+ self.close_connection()
230
303
 
231
304
  @property
232
305
  def all_tests(self):
@@ -260,29 +333,27 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
260
333
  def sync_db_fs_tests(self, retain):
261
334
  collected = retain.union(set(self.stable_test_names))
262
335
  add = list(collected - set(self.all_tests))
263
- with self.db:
264
- test_execution_file_fps = {
265
- test_name: {
266
- "deps": (
267
- {
268
- "filename": home_file(test_name),
269
- "method_checksums": methods_to_checksums(["0match"]),
270
- "mtime": None,
271
- "fsha": None,
272
- },
273
- )
274
- }
275
- for test_name in add
276
- if is_python_file(home_file(test_name))
336
+ test_execution_file_fps = {
337
+ test_name: {
338
+ "deps": (
339
+ {
340
+ "filename": home_file(test_name),
341
+ "method_checksums": methods_to_checksums(["0match"]),
342
+ "mtime": None,
343
+ "fsha": None,
344
+ },
345
+ )
277
346
  }
278
- if test_execution_file_fps:
279
- self.save_test_execution_file_fps(test_execution_file_fps)
347
+ for test_name in add
348
+ if is_python_file(home_file(test_name))
349
+ }
350
+ if test_execution_file_fps:
351
+ self.save_test_execution_file_fps(test_execution_file_fps)
280
352
 
281
353
  to_delete = list(set(self.all_tests) - collected)
282
- with self.db as database:
283
- database.delete_test_executions(to_delete, self.exec_id)
354
+ self.db.delete_test_executions(to_delete, self.exec_id)
284
355
 
285
- def determine_stable(self, assert_old=True):
356
+ def determine_stable(self):
286
357
  files_fshas = {}
287
358
  for filename in self.files_of_interest:
288
359
  module = self.source_tree.get_file(filename)
@@ -299,9 +370,6 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
299
370
  tests = self.db.determine_tests(self.exec_id, files_mhashes)
300
371
  affected_tests, self.failing_tests = tests["affected"], tests["failing"]
301
372
 
302
- if assert_old:
303
- self.assert_old_determin_stable(affected_tests)
304
-
305
373
  self.all_files = set(self.db.filenames(self.exec_id))
306
374
  self.unstable_test_names = set()
307
375
  self.unstable_files = set()
@@ -313,47 +381,6 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
313
381
  self.stable_test_names = set(self.all_tests) - self.unstable_test_names
314
382
  self.stable_files = set(self.all_files) - self.unstable_files
315
383
 
316
- def assert_old_determin_stable(self, new_fingerprint_misses):
317
- filenames_fingerprints = self.db.filenames_fingerprints(self.exec_id)
318
-
319
- _, fsha_misses = split_filter(
320
- self.source_tree, check_fsha, filenames_fingerprints
321
- ) # check 2. fsha vs filesystem
322
-
323
- # with the list of fingerprint_ids go to the database
324
- # and fetch all the data needed for next step
325
-
326
- changed_file_data = self.db.fetch_changed_file_data(
327
- [fsha_miss["fingerprint_id"] for fsha_miss in (fsha_misses)],
328
- self.exec_id,
329
- )
330
-
331
- # changed_file_data:
332
- # [(filename, test_name, method_fshas, fingerprint_id, failed )]
333
- # All the test_names in this list have a dependency on one
334
- # or more changed files. And we also have the fingerprints
335
- # of data content which they depend on. So it’s possible to
336
- # filter out the node_ids where the content of the changed file
337
- # still matches the fingerprint
338
-
339
- _, fingerprint_misses = split_filter(
340
- self.source_tree, check_fingerprint, changed_file_data
341
- )
342
-
343
- if {fingerprint_miss[1] for fingerprint_miss in fingerprint_misses} != set(
344
- new_fingerprint_misses
345
- ):
346
- print("ERROR: old and new fingerprint misses differ.. printing old algo")
347
- print(
348
- "\n".join(
349
- sorted(
350
- {fingerprint_miss[1] for fingerprint_miss in fingerprint_misses}
351
- )
352
- )
353
- )
354
- print("printing new algo")
355
- print("\n".join(sorted(new_fingerprint_misses)))
356
-
357
384
  @property
358
385
  def avg_durations(self) -> dict:
359
386
  stats = defaultdict(lambda: {"test_execution": 0, "sum_duration": 0})
@@ -420,6 +447,22 @@ def cached_relpath(path, basepath):
420
447
  return os.path.relpath(path, basepath).replace(os.sep, "/")
421
448
 
422
449
 
450
+ # coverage 7.10+ defaults to the sys.monitoring core on CPython 3.14+. That core
451
+ # doesn't support dynamic contexts: sys.monitoring disables a line event once the
452
+ # line has been recorded, so a line executed by several tests of one batch is
453
+ # attributed to the first test only and the other tests lose the dependency.
454
+ # testmon relies on contexts_by_lineno, so it needs the C tracer.
455
+ COVERAGE_CORE = "ctrace"
456
+
457
+
458
+ def force_context_capable_core(cov: Coverage):
459
+ try:
460
+ cov.set_option("run:core", COVERAGE_CORE)
461
+ except CoverageException:
462
+ # coverage < 7.10 has no core option and never defaults to sys.monitoring.
463
+ pass
464
+
465
+
423
466
  class TestmonCollector:
424
467
  coverage_stack: [Coverage] = []
425
468
 
@@ -499,9 +542,11 @@ class TestmonCollector:
499
542
  "testmon doesn't support simultaneous run with pytest-cov when "
500
543
  "branch coverage is on. Please disable branch coverage."
501
544
  )
502
-
545
+ if subprocess:
546
+ self.setup_subprocess(params["include"])
503
547
  self.cov = Coverage(data_file=self.sub_cov_file, config_file=False, **params)
504
548
  self.cov._warn_no_data = False
549
+ force_context_capable_core(self.cov)
505
550
  if TestmonCollector.coverage_stack:
506
551
  TestmonCollector.coverage_stack[-1].stop()
507
552
 
@@ -600,11 +645,35 @@ class TestmonCollector:
600
645
  TestmonCollector.coverage_stack.pop()
601
646
  if self.sub_cov_file:
602
647
  os.remove(self.sub_cov_file + "_rc")
648
+ os.environ.pop("COVERAGE_CORE", None)
603
649
  os.environ.pop("COVERAGE_PROCESS_START", None)
604
650
  self.cov = None
605
651
  if TestmonCollector.coverage_stack:
606
652
  TestmonCollector.coverage_stack[-1].start()
607
653
 
654
+ def setup_subprocess(self, includes):
655
+ # This can be redone to use functionality from pytest-cov
656
+ if not os.path.exists(".tmontmp"):
657
+ os.makedirs(".tmontmp")
658
+ self.sub_cov_file = os.path.abspath(
659
+ ".tmontmp/.testmoncoverage" + str(random.randint(0, 1000000))
660
+ )
661
+ with open(self.sub_cov_file + "_rc", "w", encoding="utf-8") as subprocess_rc:
662
+ rc_content = textwrap.dedent(
663
+ """\
664
+ [run]
665
+ data_file = {}
666
+ include = {}
667
+ parallel=True
668
+ """
669
+ ).format(
670
+ self.sub_cov_file,
671
+ "\n ".join(includes),
672
+ )
673
+ subprocess_rc.write(rc_content)
674
+ os.environ["COVERAGE_PROCESS_START"] = self.sub_cov_file + "_rc"
675
+ os.environ["COVERAGE_CORE"] = COVERAGE_CORE
676
+
608
677
 
609
678
  def eval_environment(environment, **kwargs):
610
679
  if not environment:
@@ -1,2 +0,0 @@
1
- """PYTEST_DONT_REWRITE"""
2
- TESTMON_VERSION = "2.1.4"
File without changes