pytest-testmon 2.2.0__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.2.0/pytest_testmon.egg-info → pytest_testmon-2.3.0b0}/PKG-INFO +4 -2
  2. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pyproject.toml +22 -28
  3. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0/pytest_testmon.egg-info}/PKG-INFO +4 -2
  4. pytest_testmon-2.3.0b0/testmon/__init__.py +3 -0
  5. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/testmon/configure.py +2 -1
  6. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/testmon/db.py +28 -31
  7. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/testmon/process_code.py +14 -7
  8. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/testmon/pytest_testmon.py +37 -33
  9. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/testmon/testmon_core.py +79 -27
  10. pytest_testmon-2.2.0/testmon/__init__.py +0 -2
  11. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/LICENSE +0 -0
  12. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/MANIFEST.in +0 -0
  13. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/README.md +0 -0
  14. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/SOURCES.txt +0 -0
  15. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/dependency_links.txt +0 -0
  16. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/entry_points.txt +0 -0
  17. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/requires.txt +0 -0
  18. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/pytest_testmon.egg-info/top_level.txt +0 -0
  19. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/setup.cfg +0 -0
  20. {pytest_testmon-2.2.0 → pytest_testmon-2.3.0b0}/setup.py +0 -0
  21. {pytest_testmon-2.2.0 → 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.2.0
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,7 +17,7 @@ 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.10
20
+ Requires-Python: >=3.11
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
21
23
  Requires-Dist: pytest<10,>=5
@@ -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.10"
14
+ requires-python = ">=3.11"
15
15
  keywords = ["testing", "pytest", "plugin"]
16
16
  license = { text = "MIT" }
17
17
  classifiers = [
@@ -31,34 +31,28 @@ dependencies = [
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.2.0
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,7 +17,7 @@ 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.10
20
+ Requires-Python: >=3.11
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
21
23
  Requires-Dist: pytest<10,>=5
@@ -0,0 +1,3 @@
1
+ """PYTEST_DONT_REWRITE"""
2
+
3
+ TESTMON_VERSION = "2.3.0b0"
@@ -75,7 +75,8 @@ 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
82
  file_or_dir = options.get("file_or_dir") or []
@@ -5,10 +5,8 @@ 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
11
-
9
+ from testmon.process_code import blob_to_checksums, checksums_to_blob
12
10
 
13
11
  DATA_VERSION = 14
14
12
 
@@ -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
@@ -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 {
@@ -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,14 +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
248
  text=True,
242
249
  encoding="utf-8",
243
250
  check=True,
244
251
  )
245
- except (FileNotFoundError, CalledProcessError):
252
+ except (FileNotFoundError, NotADirectoryError, CalledProcessError):
246
253
  return all_shas
247
254
 
248
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,7 +106,12 @@ 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",
@@ -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_", ""))
@@ -195,7 +202,7 @@ def init_testmon_data(config: Config):
195
202
  if running_as == "worker" and exec_id is not None:
196
203
  # Initialize for xdist worker run
197
204
  testmon_data: TestmonData = TestmonData.for_worker(
198
- rootdir=config.rootdir.strpath,
205
+ rootdir=str(config.rootpath),
199
206
  exec_id=exec_id,
200
207
  database=rpc_proxy,
201
208
  system_packages_change=system_packages_change,
@@ -205,7 +212,7 @@ def init_testmon_data(config: Config):
205
212
  else:
206
213
  # Initialize for local run (controller or single process)
207
214
  testmon_data: TestmonData = TestmonData.for_local_run(
208
- rootdir=config.rootdir.strpath,
215
+ rootdir=str(config.rootpath),
209
216
  database=rpc_proxy,
210
217
  environment=environment,
211
218
  system_packages=system_packages,
@@ -234,7 +241,7 @@ def register_plugins(config, should_select, should_collect, cov_plugin):
234
241
  config.pluginmanager.register(
235
242
  TestmonCollect(
236
243
  TestmonCollector(
237
- config.rootdir.strpath,
244
+ str(config.rootpath),
238
245
  testmon_labels=testmon_options(config),
239
246
  cov_plugin=cov_plugin,
240
247
  ),
@@ -243,8 +250,8 @@ def register_plugins(config, should_select, should_collect, cov_plugin):
243
250
  ),
244
251
  "TestmonCollect",
245
252
  )
246
- if config.pluginmanager.hasplugin("xdist"):
247
- config.pluginmanager.register(TestmonXdistSync())
253
+ if (should_select or should_collect) and config.pluginmanager.hasplugin("xdist"):
254
+ config.pluginmanager.register(TestmonXdistSync(sync=should_collect))
248
255
 
249
256
 
250
257
  def pytest_configure(config):
@@ -266,6 +273,10 @@ def pytest_configure(config):
266
273
  )
267
274
  config.testmon_config: TmConf = tm_conf
268
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.")
269
280
  try:
270
281
  init_testmon_data(config)
271
282
  register_plugins(config, tm_conf.select, tm_conf.collect, cov_plugin)
@@ -362,9 +373,7 @@ class TestmonCollect:
362
373
  self._sessionstarttime = time.time()
363
374
 
364
375
  @pytest.hookimpl(tryfirst=True, hookwrapper=True)
365
- def pytest_pycollect_makeitem(
366
- self, collector, name, obj
367
- ): # pylint: disable=unused-argument
376
+ def pytest_pycollect_makeitem(self, collector, name, obj): # pylint: disable=unused-argument
368
377
  makeitem_result = yield
369
378
  items = makeitem_result.get_result() or []
370
379
  try:
@@ -375,9 +384,7 @@ class TestmonCollect:
375
384
  pass
376
385
 
377
386
  @pytest.hookimpl(tryfirst=True)
378
- def pytest_collection_modifyitems(
379
- self, session, config, items
380
- ): # pylint: disable=unused-argument
387
+ def pytest_collection_modifyitems(self, session, config, items): # pylint: disable=unused-argument
381
388
  should_sync = not session.testsfailed and self._running_as in (
382
389
  "single",
383
390
  "controller",
@@ -386,9 +393,7 @@ class TestmonCollect:
386
393
  config.testmon_data.sync_db_fs_tests(retain=set(self.raw_test_names))
387
394
 
388
395
  @pytest.hookimpl(hookwrapper=True)
389
- def pytest_runtest_protocol(
390
- self, item, nextitem
391
- ): # pylint: disable=unused-argument
396
+ def pytest_runtest_protocol(self, item, nextitem): # pylint: disable=unused-argument
392
397
  self.testmon.start_testmon(item.nodeid, nextitem.nodeid if nextitem else None)
393
398
  result = yield
394
399
  if result.excinfo and issubclass(result.excinfo[0], BaseException):
@@ -441,8 +446,9 @@ class TestmonCollect:
441
446
 
442
447
 
443
448
  class TestmonXdistSync:
444
- def __init__(self):
449
+ def __init__(self, sync=True):
445
450
  self.await_nodes = 0
451
+ self.sync = sync
446
452
 
447
453
  def pytest_configure_node(self, node):
448
454
  """
@@ -459,25 +465,25 @@ class TestmonXdistSync:
459
465
  if running_as != "controller":
460
466
  return # Safety check: only run on controller
461
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.
471
+ self.await_nodes += 1
472
+
462
473
  # Only proceed if testmon_data has been initialized and workerinput exists
463
474
  if hasattr(node.config, "testmon_data") and hasattr(node, "workerinput"):
464
475
  testmon_data: TestmonData = node.config.testmon_data
465
476
  node.workerinput["testmon_exec_id"] = testmon_data.exec_id
466
- node.workerinput[
467
- "testmon_system_packages_change"
468
- ] = testmon_data.system_packages_change
469
- node.workerinput[
470
- "testmon_files_of_interest"
471
- ] = testmon_data.files_of_interest
472
-
473
- def pytest_testnodeready(self, node): # pylint: disable=unused-argument
474
- self.await_nodes += 1
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
+ )
475
483
 
476
- def pytest_xdist_node_collection_finished(
477
- self, node, ids
478
- ): # pylint: disable=invalid-name
484
+ def pytest_xdist_node_collection_finished(self, node, ids): # pylint: disable=invalid-name
479
485
  self.await_nodes += -1
480
- if self.await_nodes == 0:
486
+ if self.await_nodes == 0 and self.sync:
481
487
  node.config.testmon_data.sync_db_fs_tests(retain=set(ids))
482
488
 
483
489
 
@@ -530,15 +536,13 @@ class TestmonSelect:
530
536
  self._interrupted = False
531
537
 
532
538
  def pytest_ignore_collect(self, collection_path: Path, config):
533
- strpath = cached_relpath(str(collection_path), config.rootdir.strpath)
539
+ strpath = cached_relpath(str(collection_path), str(config.rootpath))
534
540
  if strpath in self.deselected_files and self.config.testmon_config.select:
535
541
  return True
536
542
  return None
537
543
 
538
544
  @pytest.hookimpl(trylast=True)
539
- def pytest_collection_modifyitems(
540
- self, session, config, items
541
- ): # pylint: disable=unused-argument
545
+ def pytest_collection_modifyitems(self, session, config, items): # pylint: disable=unused-argument
542
546
  selected = []
543
547
  deselected = []
544
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
@@ -234,9 +234,7 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
234
234
  ),
235
235
  exc,
236
236
  )
237
- self.db = db.DB(
238
- os.path.join(self.rootdir, get_data_file_path())
239
- ) # pylint: disable=invalid-name
237
+ self.db = db.DB(os.path.join(self.rootdir, get_data_file_path())) # pylint: disable=invalid-name
240
238
  result = self.db.initiate_execution(
241
239
  self.environment, system_packages, python_version, {}
242
240
  )
@@ -285,9 +283,23 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
285
283
  return False
286
284
 
287
285
  def close_connection(self):
288
- pass
289
- # TODO this is why there are "too many open files" on MacOS.
290
- # 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()
291
303
 
292
304
  @property
293
305
  def all_tests(self):
@@ -321,27 +333,25 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
321
333
  def sync_db_fs_tests(self, retain):
322
334
  collected = retain.union(set(self.stable_test_names))
323
335
  add = list(collected - set(self.all_tests))
324
- with self.db:
325
- test_execution_file_fps = {
326
- test_name: {
327
- "deps": (
328
- {
329
- "filename": home_file(test_name),
330
- "method_checksums": methods_to_checksums(["0match"]),
331
- "mtime": None,
332
- "fsha": None,
333
- },
334
- )
335
- }
336
- for test_name in add
337
- 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
+ )
338
346
  }
339
- if test_execution_file_fps:
340
- 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)
341
352
 
342
353
  to_delete = list(set(self.all_tests) - collected)
343
- with self.db as database:
344
- database.delete_test_executions(to_delete, self.exec_id)
354
+ self.db.delete_test_executions(to_delete, self.exec_id)
345
355
 
346
356
  def determine_stable(self):
347
357
  files_fshas = {}
@@ -437,6 +447,22 @@ def cached_relpath(path, basepath):
437
447
  return os.path.relpath(path, basepath).replace(os.sep, "/")
438
448
 
439
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
+
440
466
  class TestmonCollector:
441
467
  coverage_stack: [Coverage] = []
442
468
 
@@ -516,9 +542,11 @@ class TestmonCollector:
516
542
  "testmon doesn't support simultaneous run with pytest-cov when "
517
543
  "branch coverage is on. Please disable branch coverage."
518
544
  )
519
-
545
+ if subprocess:
546
+ self.setup_subprocess(params["include"])
520
547
  self.cov = Coverage(data_file=self.sub_cov_file, config_file=False, **params)
521
548
  self.cov._warn_no_data = False
549
+ force_context_capable_core(self.cov)
522
550
  if TestmonCollector.coverage_stack:
523
551
  TestmonCollector.coverage_stack[-1].stop()
524
552
 
@@ -617,11 +645,35 @@ class TestmonCollector:
617
645
  TestmonCollector.coverage_stack.pop()
618
646
  if self.sub_cov_file:
619
647
  os.remove(self.sub_cov_file + "_rc")
648
+ os.environ.pop("COVERAGE_CORE", None)
620
649
  os.environ.pop("COVERAGE_PROCESS_START", None)
621
650
  self.cov = None
622
651
  if TestmonCollector.coverage_stack:
623
652
  TestmonCollector.coverage_stack[-1].start()
624
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
+
625
677
 
626
678
  def eval_environment(environment, **kwargs):
627
679
  if not environment:
@@ -1,2 +0,0 @@
1
- """PYTEST_DONT_REWRITE"""
2
- TESTMON_VERSION = "2.2.0"
File without changes