pytest-testmon 2.1.4__tar.gz → 2.2.0__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 (20) hide show
  1. {pytest_testmon-2.1.4/pytest_testmon.egg-info → pytest_testmon-2.2.0}/PKG-INFO +3 -3
  2. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pyproject.toml +3 -3
  3. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0/pytest_testmon.egg-info}/PKG-INFO +3 -3
  4. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pytest_testmon.egg-info/requires.txt +1 -1
  5. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/__init__.py +1 -1
  6. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/configure.py +2 -1
  7. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/db.py +19 -6
  8. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/process_code.py +2 -1
  9. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/pytest_testmon.py +74 -16
  10. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/testmon_core.py +80 -63
  11. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/LICENSE +0 -0
  12. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/MANIFEST.in +0 -0
  13. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/README.md +0 -0
  14. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pytest_testmon.egg-info/SOURCES.txt +0 -0
  15. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pytest_testmon.egg-info/dependency_links.txt +0 -0
  16. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pytest_testmon.egg-info/entry_points.txt +0 -0
  17. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/pytest_testmon.egg-info/top_level.txt +0 -0
  18. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/setup.cfg +0 -0
  19. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/setup.py +0 -0
  20. {pytest_testmon-2.1.4 → pytest_testmon-2.2.0}/testmon/common.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-testmon
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: selects tests affected by changed files and methods
5
5
  Author-email: Tibor Arpas <tibor@testmon.org>
6
6
  License: MIT
@@ -15,10 +15,10 @@ Classifier: Topic :: Software Development :: Libraries
15
15
  Classifier: Topic :: Utilities
16
16
  Classifier: Programming Language :: Python
17
17
  Classifier: Framework :: Pytest
18
- Requires-Python: >=3.8
18
+ Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: pytest<9,>=5
21
+ Requires-Dist: pytest<10,>=5
22
22
  Requires-Dist: coverage<8,>=6
23
23
  Dynamic: license-file
24
24
 
@@ -11,7 +11,7 @@ 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.10"
15
15
  keywords = ["testing", "pytest", "plugin"]
16
16
  license = { text = "MIT" }
17
17
  classifiers = [
@@ -27,7 +27,7 @@ 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
 
@@ -61,4 +61,4 @@ exclude = '''
61
61
 
62
62
 
63
63
  [project.entry-points.pytest11]
64
- pytest-testmon = "testmon.pytest_testmon"
64
+ pytest-testmon = "testmon.pytest_testmon"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-testmon
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: selects tests affected by changed files and methods
5
5
  Author-email: Tibor Arpas <tibor@testmon.org>
6
6
  License: MIT
@@ -15,10 +15,10 @@ Classifier: Topic :: Software Development :: Libraries
15
15
  Classifier: Topic :: Utilities
16
16
  Classifier: Programming Language :: Python
17
17
  Classifier: Framework :: Pytest
18
- Requires-Python: >=3.8
18
+ Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: pytest<9,>=5
21
+ Requires-Dist: pytest<10,>=5
22
22
  Requires-Dist: coverage<8,>=6
23
23
  Dynamic: license-file
24
24
 
@@ -1,2 +1,2 @@
1
- pytest<9,>=5
1
+ pytest<10,>=5
2
2
  coverage<8,>=6
@@ -1,2 +1,2 @@
1
1
  """PYTEST_DONT_REWRITE"""
2
- TESTMON_VERSION = "2.1.4"
2
+ TESTMON_VERSION = "2.2.0"
@@ -78,7 +78,8 @@ def _get_noselect_reasons(options):
78
78
  if options["lf"]:
79
79
  return ["--lf was used"]
80
80
 
81
- if any(re.match(r"(.*)\.py::(.*)", opt) for opt in options["file_or_dir"] or []):
81
+ file_or_dir = options.get("file_or_dir") or []
82
+ if any(re.match(r"(.*)\.py::(.*)", opt) for opt in file_or_dir):
82
83
  return ["you selected tests manually"]
83
84
 
84
85
  return []
@@ -10,7 +10,7 @@ from testmon.process_code import blob_to_checksums, checksums_to_blob
10
10
  from testmon.common import TestExecutions
11
11
 
12
12
 
13
- DATA_VERSION = 13
13
+ DATA_VERSION = 14
14
14
 
15
15
  ChangedFileData = namedtuple(
16
16
  "ChangedFileData", "filename name method_checksums id failed"
@@ -359,7 +359,7 @@ class DB: # pylint: disable=too-many-public-methods
359
359
  duration FLOAT,
360
360
  failed BIT,
361
361
  forced BIT,
362
- FOREIGN KEY({self._test_execution_fk_column()}) REFERENCES {self._test_execution_fk_table()}(id));
362
+ FOREIGN KEY({self._test_execution_fk_column()}) REFERENCES {self._test_execution_fk_table()}(id) ON DELETE CASCADE);
363
363
  CREATE INDEX test_execution_fk_name ON test_execution ({self._test_execution_fk_column()}, test_name);
364
364
  """
365
365
 
@@ -394,7 +394,7 @@ class DB: # pylint: disable=too-many-public-methods
394
394
  CREATE TABLE test_execution_file_fp (
395
395
  test_execution_id INTEGER,
396
396
  fingerprint_id INTEGER,
397
- FOREIGN KEY(test_execution_id) REFERENCES test_execution(id),
397
+ FOREIGN KEY(test_execution_id) REFERENCES test_execution(id) ON DELETE CASCADE,
398
398
  FOREIGN KEY(fingerprint_id) REFERENCES file_fp(id)
399
399
  );
400
400
  CREATE INDEX test_execution_file_fp_both ON test_execution_file_fp (test_execution_id, fingerprint_id);
@@ -403,7 +403,7 @@ class DB: # pylint: disable=too-many-public-methods
403
403
  suite_execution_id INTEGER,
404
404
  filename TEXT,
405
405
  fsha text,
406
- FOREIGN KEY(suite_execution_id) REFERENCES suite_execution(id)
406
+ FOREIGN KEY(suite_execution_id) REFERENCES suite_execution(id) ON DELETE CASCADE
407
407
  );
408
408
  CREATE UNIQUE INDEX sefch_suite_id_filename_sha ON suite_execution_file_fsha(suite_execution_id, filename, fsha);
409
409
  """
@@ -659,7 +659,7 @@ class DB: # pylint: disable=too-many-public-methods
659
659
  ORDER BY id DESC
660
660
  """,
661
661
  (environment_name,),
662
- ).fetchone()
662
+ ).fetchone() # TODO to pick best environment of many with the same name, we would need to fetch all.
663
663
 
664
664
  if environment:
665
665
  environment_id = environment["id"]
@@ -669,7 +669,7 @@ class DB: # pylint: disable=too-many-public-methods
669
669
  )
670
670
  else:
671
671
  packages_changed = False
672
- if not environment or packages_changed:
672
+ if not environment:
673
673
  try:
674
674
  cursor.execute(
675
675
  """
@@ -694,6 +694,19 @@ class DB: # pylint: disable=too-many-public-methods
694
694
  (environment_name,),
695
695
  ).fetchone()
696
696
  environment_id = environment["id"]
697
+ elif packages_changed:
698
+ cursor.execute(
699
+ """
700
+ INSERT INTO environment (environment_name, system_packages, python_version)
701
+ VALUES (?, ?, ?)
702
+ """,
703
+ (environment_name, system_packages, python_version),
704
+ )
705
+ new_environment_id = cursor.lastrowid
706
+ cursor.execute(
707
+ "DELETE FROM environment WHERE id = ?", (environment_id,)
708
+ )
709
+ environment_id = new_environment_id
697
710
 
698
711
  return environment_id, packages_changed
699
712
 
@@ -238,7 +238,8 @@ def noncached_get_files_shas(directory):
238
238
  result = run(
239
239
  ["git", "ls-files", "--stage", "-m", directory],
240
240
  capture_output=True,
241
- universal_newlines=True,
241
+ text=True,
242
+ encoding="utf-8",
242
243
  check=True,
243
244
  )
244
245
  except (FileNotFoundError, CalledProcessError):
@@ -111,7 +111,7 @@ def pytest_addoption(parser):
111
111
  action="store_true",
112
112
  dest="tmnet",
113
113
  help=(
114
- "This is used for internal beta. Please don't use. You can go to https://www.testmon.net/ to register."
114
+ "Use tmnet cloud for storage instead of .testmondata file used by --testmon (see https://www.testmon.net)."
115
115
  ),
116
116
  )
117
117
 
@@ -165,24 +165,52 @@ def init_testmon_data(config: Config):
165
165
  )
166
166
  else:
167
167
  tmnet_api_key = os.getenv("TMNET_API_KEY")
168
- elif tmnet_api_key is None:
169
- logger.warning(
170
- "TMNET_API_KEY not set.",
168
+
169
+ if not tmnet_api_key.strip():
170
+ raise ValueError(
171
+ "TMNET_API_KEY is required when using --tmnet. "
172
+ "Please set it in pytest.ini, pyproject.toml, or as an environment variable. "
171
173
  )
174
+
172
175
  rpc_proxy = xmlrpc.client.ServerProxy(
173
176
  url,
174
177
  allow_none=True,
175
- headers=[("x-api-key", tmnet_api_key)],
178
+ headers=[("x-api-key", tmnet_api_key.strip())],
176
179
  )
177
180
 
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))
181
+ # Check if we're a worker and have exec_id from controller
182
+ running_as = get_running_as(config)
183
+ exec_id = None
184
+ system_packages_change = None
185
+ files_of_interest = None
186
+
187
+ if running_as == "worker" and hasattr(config, "workerinput"):
188
+ # Use exec_id sent from controller
189
+ exec_id = config.workerinput.get("testmon_exec_id")
190
+ system_packages_change = config.workerinput.get(
191
+ "testmon_system_packages_change"
192
+ )
193
+ files_of_interest = config.workerinput.get("testmon_files_of_interest")
194
+
195
+ if running_as == "worker" and exec_id is not None:
196
+ # Initialize for xdist worker run
197
+ testmon_data: TestmonData = TestmonData.for_worker(
198
+ rootdir=config.rootdir.strpath,
199
+ exec_id=exec_id,
200
+ database=rpc_proxy,
201
+ system_packages_change=system_packages_change,
202
+ files_of_interest=files_of_interest,
203
+ environment=environment,
204
+ )
205
+ else:
206
+ # Initialize for local run (controller or single process)
207
+ testmon_data: TestmonData = TestmonData.for_local_run(
208
+ rootdir=config.rootdir.strpath,
209
+ database=rpc_proxy,
210
+ environment=environment,
211
+ system_packages=system_packages,
212
+ )
213
+ testmon_data.determine_stable()
186
214
  config.testmon_data = testmon_data
187
215
 
188
216
 
@@ -321,7 +349,9 @@ def pytest_unconfigure(config):
321
349
 
322
350
 
323
351
  class TestmonCollect:
324
- def __init__(self, testmon, testmon_data, running_as="single", cov_plugin=None):
352
+ def __init__(
353
+ self, testmon, testmon_data: TestmonData, running_as="single", cov_plugin=None
354
+ ):
325
355
  self.testmon_data: TestmonData = testmon_data
326
356
  self.testmon: TestmonCollector = testmon
327
357
  self._running_as = running_as
@@ -371,7 +401,9 @@ class TestmonCollect:
371
401
  if call.when == "teardown":
372
402
  report = result.get_result()
373
403
  report.nodes_files_lines = self.testmon.get_batch_coverage_data()
374
- result.force_result(report)
404
+ result.force_result(
405
+ report
406
+ ) # under xdist, report is serialized on the worker and sent to the controller
375
407
 
376
408
  @pytest.hookimpl
377
409
  def pytest_runtest_logreport(self, report):
@@ -412,6 +444,32 @@ class TestmonXdistSync:
412
444
  def __init__(self):
413
445
  self.await_nodes = 0
414
446
 
447
+ def pytest_configure_node(self, node):
448
+ """
449
+ Send exec_id and related data from controller to worker during xdist initialization.
450
+ This avoids each worker having to independently determine the environment and check
451
+ for package changes.
452
+
453
+ Note: This hook is called on the controller side for each worker node.
454
+ The node.config here is the controller's config, not the worker's config.
455
+ """
456
+ # Verify we're on the controller (not a worker)
457
+ # node.config in this hook is the controller's config
458
+ running_as = get_running_as(node.config)
459
+ if running_as != "controller":
460
+ return # Safety check: only run on controller
461
+
462
+ # Only proceed if testmon_data has been initialized and workerinput exists
463
+ if hasattr(node.config, "testmon_data") and hasattr(node, "workerinput"):
464
+ testmon_data: TestmonData = node.config.testmon_data
465
+ 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
+
415
473
  def pytest_testnodeready(self, node): # pylint: disable=unused-argument
416
474
  self.await_nodes += 1
417
475
 
@@ -455,7 +513,7 @@ def format_time_saved(seconds):
455
513
 
456
514
 
457
515
  class TestmonSelect:
458
- def __init__(self, config, testmon_data):
516
+ def __init__(self, config, testmon_data: TestmonData):
459
517
  self.testmon_data: TestmonData = testmon_data
460
518
  self.config = config
461
519
 
@@ -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,
@@ -207,21 +240,49 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
207
240
  result = self.db.initiate_execution(
208
241
  self.environment, system_packages, python_version, {}
209
242
  )
210
- self.exec_id = result["exec_id"]
211
243
 
244
+ self.exec_id = result["exec_id"]
212
245
  self.system_packages_change = result["packages_changed"]
213
246
  self.files_of_interest = result["filenames"]
214
247
 
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
248
+ @classmethod
249
+ def for_worker( # pylint: disable=too-many-arguments
250
+ cls,
251
+ rootdir,
252
+ exec_id,
253
+ database=None,
254
+ system_packages_change=None,
255
+ files_of_interest=None,
256
+ environment=None,
257
+ ):
258
+ instance = cls(rootdir, database=database, readonly=True)
259
+ instance._init_for_worker(
260
+ exec_id, system_packages_change, files_of_interest, environment
261
+ )
262
+ return instance
263
+
264
+ def _init_for_worker(
265
+ self,
266
+ exec_id,
267
+ system_packages_change=None,
268
+ files_of_interest=None,
269
+ environment=None,
270
+ ):
271
+ """Internal method to initialize for worker run."""
272
+ self.exec_id = exec_id
273
+ self.system_packages_change = system_packages_change
274
+ self.files_of_interest = files_of_interest
275
+ if environment:
276
+ self.environment = environment
277
+ elif not hasattr(self, "environment") or self.environment is None:
278
+ self.environment = "default"
221
279
 
222
280
  @property
223
281
  def new_db(self):
224
- return self.db.file_created
282
+ # Only local DB instances have file_created attribute
283
+ if isinstance(self.db, db.DB):
284
+ return self.db.file_created
285
+ return False
225
286
 
226
287
  def close_connection(self):
227
288
  pass
@@ -282,7 +343,7 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
282
343
  with self.db as database:
283
344
  database.delete_test_executions(to_delete, self.exec_id)
284
345
 
285
- def determine_stable(self, assert_old=True):
346
+ def determine_stable(self):
286
347
  files_fshas = {}
287
348
  for filename in self.files_of_interest:
288
349
  module = self.source_tree.get_file(filename)
@@ -299,9 +360,6 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
299
360
  tests = self.db.determine_tests(self.exec_id, files_mhashes)
300
361
  affected_tests, self.failing_tests = tests["affected"], tests["failing"]
301
362
 
302
- if assert_old:
303
- self.assert_old_determin_stable(affected_tests)
304
-
305
363
  self.all_files = set(self.db.filenames(self.exec_id))
306
364
  self.unstable_test_names = set()
307
365
  self.unstable_files = set()
@@ -313,47 +371,6 @@ class TestmonData: # pylint: disable=too-many-instance-attributes
313
371
  self.stable_test_names = set(self.all_tests) - self.unstable_test_names
314
372
  self.stable_files = set(self.all_files) - self.unstable_files
315
373
 
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
374
  @property
358
375
  def avg_durations(self) -> dict:
359
376
  stats = defaultdict(lambda: {"test_execution": 0, "sum_duration": 0})
File without changes
File without changes
File without changes
File without changes