pytest-isolate 0.0.3__tar.gz → 0.0.8__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.
@@ -16,8 +16,8 @@ jobs:
16
16
  os:
17
17
  - Ubuntu
18
18
  py:
19
- - "3.11"
20
- - "3.7"
19
+ - "3.12"
20
+ - "3.9"
21
21
  steps:
22
22
  - name: Setup python for test ${{ matrix.py }}
23
23
  uses: actions/setup-python@v2
@@ -1,25 +1,28 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: pytest-isolate
3
- Version: 0.0.3
4
- Summary: Run pytest tests in isolated subprocesses
3
+ Version: 0.0.8
4
+ Summary: Run pytest tests in isolated subprocesses
5
5
  Project-URL: Documentation, https://github.com/gilfree/pytest-isolate#readme
6
6
  Project-URL: Issues, https://github.com/gilfree/pytest-isolate/issues
7
7
  Project-URL: Source, https://github.com/gilfree/pytest-isolate
8
8
  Author-email: gilfree <gilfree@users.noreply.github.com>
9
- License: MIT
9
+ License-Expression: MIT
10
+ License-File: LICENSE.txt
10
11
  Classifier: Development Status :: 4 - Beta
11
12
  Classifier: Programming Language :: Python
12
- Classifier: Programming Language :: Python :: 3.7
13
- Classifier: Programming Language :: Python :: 3.8
14
13
  Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: Implementation :: CPython
18
18
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.7
20
20
  Requires-Dist: dill
21
+ Requires-Dist: filelock
21
22
  Requires-Dist: pytest
22
23
  Requires-Dist: tblib
24
+ Provides-Extra: gpu
25
+ Requires-Dist: pynvml; extra == 'gpu'
23
26
  Description-Content-Type: text/markdown
24
27
 
25
28
  # pytest-isolate
@@ -39,19 +42,32 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
39
42
  * Captures stdout & stderr of crashing processes
40
43
  * Add Timeout to a forked test
41
44
  * Limit memory used by test
45
+ * Limit CPU time used by test
46
+ * Manage GPU resources with CUDA_VISIBLE_DEVICES
42
47
  * Plays nice with pytest-xdist
43
48
  * Shows warnings, even with xdist!
49
+ * *Create visual timeline of test execution (isolated or not)*
44
50
 
45
51
  ## Requirements
46
52
 
47
53
  * pytest
48
54
 
55
+ ### Optional Dependencies
56
+
57
+ For GPU resource management:
58
+
59
+ * pynvml (optional, for automatic GPU detection)
60
+
49
61
  ## Installation
50
62
 
51
63
  You can install "pytest-isolate" via `pip` from `PyPI`
52
64
 
53
65
  pip install pytest-isolate
54
66
 
67
+ For GPU resource management support:
68
+
69
+ pip install pytest-isolate[gpu]
70
+
55
71
  ## Usage
56
72
 
57
73
  pytest --isolate
@@ -64,6 +80,13 @@ Or:
64
80
 
65
81
  To set a timeout to every test in addition to forking, and limit to 10 cpu seconds.
66
82
 
83
+ Or:
84
+
85
+ pytest --timeline
86
+
87
+ With possible combination of the above, to generate a timeline of test execution. The
88
+ timeline can be viewed in chrome://tracing.
89
+
67
90
  > Note:
68
91
  >
69
92
  > Since this plugin uses `fork`, it will not work on operating systems without `fork` support (e.g. Windows).
@@ -75,7 +98,9 @@ they will take precedence. Uninstall them to use `pytest-isolate`.
75
98
 
76
99
  Unlike `pytest-timeout`, timeout in `pytest-isolate` is implemented by forking the test to a separate subprocess, and setting timeout for that subprocess.
77
100
 
78
- You can also use a mark to isolate or time limit the memory and/or cpu usage test:
101
+ ### Using the isolate marker
102
+
103
+ You can use a mark to isolate or time limit the memory and/or cpu usage test:
79
104
 
80
105
  ```python
81
106
  @pytest.mark.isolate(timeout=10, mem_limit=10**6, cpu_limit=10)
@@ -83,7 +108,19 @@ def test_something():
83
108
  pass
84
109
  ```
85
110
 
86
- The options can also be set in an pytest configuration file, e.g:
111
+ The `isolate` marker can also be used to request gpus for a test on a gpu machine:
112
+
113
+ ```python
114
+ # Request 2 GPUs for this test
115
+ @pytest.mark.isolate(resources={'gpu': 2})
116
+ def test_with_gpus():
117
+ # The test will have CUDA_VISIBLE_DEVICES set to the allocated GPU IDs
118
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES").split(",")) == 2
119
+ ```
120
+
121
+ ### Configuration Options
122
+
123
+ The options can be set in an pytest configuration file, e.g:
87
124
 
88
125
  ```toml
89
126
  [tool.pytest.ini_options]
@@ -92,6 +129,15 @@ isolate_mem_limit=1000000
92
129
  isolate_cpu_limit=10
93
130
  ```
94
131
 
132
+ ## CUDA_VISIBLE_DEVICES Handling
133
+
134
+ If `CUDA_VISIBLE_DEVICES` is already set when pytest starts, the plugin will respect this setting and only allocate from the GPUs specified there. This works even without pynvml installed.
135
+
136
+ For example:
137
+
138
+ * If `CUDA_VISIBLE_DEVICES=0,1,2` is set, tests will only use GPUs 0, 1, and 2.
139
+ * If `CUDA_VISIBLE_DEVICES=` is set (empty), no GPUs will be used.
140
+
95
141
  ## Contributing
96
142
 
97
143
  Contributions are very welcome. Tests can be run with `tox`, please ensure
@@ -15,19 +15,32 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
15
15
  * Captures stdout & stderr of crashing processes
16
16
  * Add Timeout to a forked test
17
17
  * Limit memory used by test
18
+ * Limit CPU time used by test
19
+ * Manage GPU resources with CUDA_VISIBLE_DEVICES
18
20
  * Plays nice with pytest-xdist
19
21
  * Shows warnings, even with xdist!
22
+ * *Create visual timeline of test execution (isolated or not)*
20
23
 
21
24
  ## Requirements
22
25
 
23
26
  * pytest
24
27
 
28
+ ### Optional Dependencies
29
+
30
+ For GPU resource management:
31
+
32
+ * pynvml (optional, for automatic GPU detection)
33
+
25
34
  ## Installation
26
35
 
27
36
  You can install "pytest-isolate" via `pip` from `PyPI`
28
37
 
29
38
  pip install pytest-isolate
30
39
 
40
+ For GPU resource management support:
41
+
42
+ pip install pytest-isolate[gpu]
43
+
31
44
  ## Usage
32
45
 
33
46
  pytest --isolate
@@ -40,6 +53,13 @@ Or:
40
53
 
41
54
  To set a timeout to every test in addition to forking, and limit to 10 cpu seconds.
42
55
 
56
+ Or:
57
+
58
+ pytest --timeline
59
+
60
+ With possible combination of the above, to generate a timeline of test execution. The
61
+ timeline can be viewed in chrome://tracing.
62
+
43
63
  > Note:
44
64
  >
45
65
  > Since this plugin uses `fork`, it will not work on operating systems without `fork` support (e.g. Windows).
@@ -51,7 +71,9 @@ they will take precedence. Uninstall them to use `pytest-isolate`.
51
71
 
52
72
  Unlike `pytest-timeout`, timeout in `pytest-isolate` is implemented by forking the test to a separate subprocess, and setting timeout for that subprocess.
53
73
 
54
- You can also use a mark to isolate or time limit the memory and/or cpu usage test:
74
+ ### Using the isolate marker
75
+
76
+ You can use a mark to isolate or time limit the memory and/or cpu usage test:
55
77
 
56
78
  ```python
57
79
  @pytest.mark.isolate(timeout=10, mem_limit=10**6, cpu_limit=10)
@@ -59,7 +81,19 @@ def test_something():
59
81
  pass
60
82
  ```
61
83
 
62
- The options can also be set in an pytest configuration file, e.g:
84
+ The `isolate` marker can also be used to request gpus for a test on a gpu machine:
85
+
86
+ ```python
87
+ # Request 2 GPUs for this test
88
+ @pytest.mark.isolate(resources={'gpu': 2})
89
+ def test_with_gpus():
90
+ # The test will have CUDA_VISIBLE_DEVICES set to the allocated GPU IDs
91
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES").split(",")) == 2
92
+ ```
93
+
94
+ ### Configuration Options
95
+
96
+ The options can be set in an pytest configuration file, e.g:
63
97
 
64
98
  ```toml
65
99
  [tool.pytest.ini_options]
@@ -68,6 +102,15 @@ isolate_mem_limit=1000000
68
102
  isolate_cpu_limit=10
69
103
  ```
70
104
 
105
+ ## CUDA_VISIBLE_DEVICES Handling
106
+
107
+ If `CUDA_VISIBLE_DEVICES` is already set when pytest starts, the plugin will respect this setting and only allocate from the GPUs specified there. This works even without pynvml installed.
108
+
109
+ For example:
110
+
111
+ * If `CUDA_VISIBLE_DEVICES=0,1,2` is set, tests will only use GPUs 0, 1, and 2.
112
+ * If `CUDA_VISIBLE_DEVICES=` is set (empty), no GPUs will be used.
113
+
71
114
  ## Contributing
72
115
 
73
116
  Contributions are very welcome. Tests can be run with `tox`, please ensure
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-isolate"
7
- description = 'Run pytest tests in isolated subprocesses'
7
+ description = 'Run pytest tests in isolated subprocesses '
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.7"
10
10
  license = "MIT"
@@ -13,17 +13,19 @@ authors = [{ name = "gilfree", email = "gilfree@users.noreply.github.com" }]
13
13
  classifiers = [
14
14
  "Development Status :: 4 - Beta",
15
15
  "Programming Language :: Python",
16
- "Programming Language :: Python :: 3.7",
17
- "Programming Language :: Python :: 3.8",
18
16
  "Programming Language :: Python :: 3.9",
19
17
  "Programming Language :: Python :: 3.10",
20
18
  "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: Implementation :: CPython",
22
21
  "Programming Language :: Python :: Implementation :: PyPy",
23
22
  ]
24
- dependencies = ["dill", "tblib", "pytest"]
23
+ dependencies = ["dill", "tblib", "pytest","filelock"]
25
24
  dynamic = ["version"]
26
25
 
26
+ [project.optional-dependencies]
27
+ gpu = ["pynvml"]
28
+
27
29
  [project.urls]
28
30
  Documentation = "https://github.com/gilfree/pytest-isolate#readme"
29
31
  Issues = "https://github.com/gilfree/pytest-isolate/issues"
@@ -0,0 +1 @@
1
+ __version__ = "0.0.8"
@@ -1,12 +1,13 @@
1
1
  # This file is was originally based on pytest forked:
2
2
  # see: https://github.com/pytest-dev/pytest-forked/blob/master/src/pytest_forked/__init__.py
3
- # Since pytest forked is un unmaintained I decided to make my
3
+ # Since pytest forked is un unmaintained I decided to make my
4
4
  # changes in a different project
5
5
  # Please see README.md
6
6
 
7
7
 
8
8
  import fcntl
9
9
  import io
10
+ import json
10
11
  import multiprocessing as mp
11
12
  import os
12
13
  import resource
@@ -28,7 +29,7 @@ except ImportError:
28
29
  pass
29
30
 
30
31
  try:
31
- import setproctitle # noqa F401
32
+ import setproctitle # noqa F401
32
33
  except ImportError:
33
34
  pass
34
35
 
@@ -39,13 +40,50 @@ try:
39
40
  except ImportError:
40
41
  pass
41
42
 
43
+ from pytest_isolate.resource_management import (
44
+ clean_resources,
45
+ cleanup_resource_environment,
46
+ parse_resource_list,
47
+ register_resource_provider,
48
+ setup_resource_environment,
49
+ )
50
+
51
+
52
+ def get_available_gpus() -> List[int]:
53
+ # Check if CUDA_VISIBLE_DEVICES is already set
54
+ cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
55
+ if cuda_visible_devices is not None:
56
+ return parse_resource_list(cuda_visible_devices)
57
+
58
+ # If not set and pynvml is available, get all GPUs
59
+ try:
60
+ import pynvml
61
+
62
+ pynvml.nvmlInit()
63
+ count = pynvml.nvmlDeviceGetCount()
64
+ resources = list(range(count))
65
+ pynvml.nvmlShutdown()
66
+ return resources
67
+ except Exception:
68
+ warnings.warn("Failed to get GPU count using pynvml", RuntimeWarning)
69
+
70
+ return []
71
+
42
72
 
43
73
  def forked_subprocess(
44
- target, args=(), timeout=None, memlimt=None, cpulimit=None
74
+ target,
75
+ args=(),
76
+ timeout=None,
77
+ memlimt=None,
78
+ cpulimit=None,
79
+ wait_delta=0.05,
80
+ resource_dict=None,
81
+ test_id=None,
82
+ resource_timeout=None,
45
83
  ) -> Tuple[Any, int, bool]:
46
- sub = ForkedSubprocess()
84
+ sub = ForkedSubprocess(wait_delta=wait_delta)
47
85
  exitcode, timed_out, result = sub.run_in_subprocess(
48
- timeout, memlimt, cpulimit, target, args
86
+ timeout, memlimt, cpulimit, resource_dict, test_id, resource_timeout, target, args
49
87
  )
50
88
  return result, exitcode, timed_out
51
89
 
@@ -72,7 +110,8 @@ def limits(max_mem, max_cpu):
72
110
 
73
111
 
74
112
  class ForkedSubprocess:
75
- def __init__(self) -> None:
113
+ def __init__(self, wait_delta=0.05) -> None:
114
+ self.wait_delta = wait_delta
76
115
  self.r_out, self.w_out = os.pipe()
77
116
  self.r_err, self.w_err = os.pipe()
78
117
  fcntl.fcntl(self.r_err, fcntl.F_SETFL, os.O_NONBLOCK)
@@ -102,7 +141,17 @@ class ForkedSubprocess:
102
141
  os.close(self.w_out)
103
142
  os.close(self.w_err)
104
143
 
105
- def run_in_subprocess(self, timeout, memlimit, cpulimit, target, args=()):
144
+ def run_in_subprocess(
145
+ self,
146
+ timeout,
147
+ memlimit,
148
+ cpulimit,
149
+ resource_dict,
150
+ test_id,
151
+ resource_timeout,
152
+ target,
153
+ args=(),
154
+ ):
106
155
  ctx = mp.get_context("fork")
107
156
  q = ctx.Queue()
108
157
  timed_out = None
@@ -112,7 +161,8 @@ class ForkedSubprocess:
112
161
  self.child_redirect_streams()
113
162
  try:
114
163
  with limits(memlimit, cpulimit):
115
- q.put(dill.dumps(target(*args)))
164
+ with allocate_resources(resource_dict, test_id, resource_timeout):
165
+ q.put(dill.dumps(target(*args)))
116
166
  except BaseException as e:
117
167
  q.put(dill.dumps(e))
118
168
  sys.stdout.flush()
@@ -122,16 +172,17 @@ class ForkedSubprocess:
122
172
  p = ctx.Process(target=run_subprocess)
123
173
  p.start()
124
174
  self.parent_open_streams()
125
- time_left = timeout or 1
175
+ delta = self.wait_delta
176
+ time_left = timeout or delta
126
177
  result = None
127
178
  while time_left > 0:
128
179
  try:
129
- result = dill.loads(q.get(block=True, timeout=min(1, time_left)))
180
+ result = dill.loads(q.get(block=True, timeout=min(delta, time_left)))
130
181
  except Empty:
131
182
  if not p.is_alive():
132
183
  break
133
184
  if timeout is not None:
134
- time_left = time_left - 1
185
+ time_left = time_left - delta
135
186
  except Exception as e:
136
187
  result = e
137
188
  break
@@ -162,7 +213,7 @@ def pytest_load_initial_conftests(early_config, parser, args):
162
213
  )
163
214
  early_config.addinivalue_line(
164
215
  "markers",
165
- "isolate: Always isolate this test.",
216
+ "isolate: Always isolate this test, possibly with resources.",
166
217
  )
167
218
  early_config.addinivalue_line(
168
219
  "markers",
@@ -208,6 +259,35 @@ def pytest_addoption(parser):
208
259
  parser.addini(
209
260
  "isolate_cpu_limit", "Default cpu limit for isolated tests", type="string"
210
261
  )
262
+ parser.addini(
263
+ "wait_delta",
264
+ "Delta between subprocess queue pollings",
265
+ type="string",
266
+ default="0.05",
267
+ )
268
+ parser.addini(
269
+ "resource_timeout",
270
+ "Timeout for resource allocation",
271
+ type="string",
272
+ default="300",
273
+ )
274
+ parser.addoption(
275
+ "--timeline",
276
+ dest="timeline",
277
+ action="store_true",
278
+ default=False,
279
+ help="Report test timelines",
280
+ )
281
+
282
+ parser.addoption(
283
+ "--timeline-file",
284
+ dest="timeline_file",
285
+ default="./timeline.json",
286
+ help="Path to the timeline file",
287
+ )
288
+
289
+ parser.addini("timeline_file", "timeline file", type="string")
290
+
211
291
 
212
292
 
213
293
  @pytest.hookimpl(trylast=True)
@@ -235,6 +315,8 @@ def pytest_configure(config):
235
315
  "markers",
236
316
  "timeout: Run this tests in a separate, forked, process with timeout",
237
317
  )
318
+ clean_resources()
319
+ register_resource_provider("gpu", "CUDA_VISIBLE_DEVICES", get_available_gpus)
238
320
 
239
321
 
240
322
  # Taken from pytest 7.2:
@@ -296,23 +378,56 @@ def run_subprocess(item: pytest.Item):
296
378
  config=item.config, report=report
297
379
  )
298
380
  )
381
+
299
382
  return s_reports, warnings
300
383
  except BaseException as e:
301
384
  return e, None
302
385
 
303
386
 
387
+ @contextmanager
388
+ def allocate_resources(resource_dict, test_id, timeout):
389
+ resource_dict = resource_dict or {}
390
+ allocated_resources = {}
391
+ for resource_type, count in resource_dict.items():
392
+ # Allocate resources
393
+ resource_ids = setup_resource_environment(
394
+ test_id=f"{test_id}_{resource_type}",
395
+ count=count,
396
+ resource_type=resource_type,
397
+ wait_timeout=timeout,
398
+ )
399
+ allocated_resources[resource_type] = resource_ids
400
+ try:
401
+ yield allocated_resources
402
+ finally:
403
+ for res_type, _ in allocated_resources.items():
404
+ cleanup_resource_environment(f"{test_id}_{res_type}", res_type)
405
+
406
+
304
407
  def run_in_subprocess(
305
408
  item: pytest.Item,
306
409
  timeout: Optional[float],
307
410
  mem_limit: Optional[int],
308
411
  cpu_limit: Optional[int],
412
+ wait_delta: float,
309
413
  ) -> List[pytest.TestReport]:
310
414
  cap = item.config.pluginmanager.getplugin("capturemanager")
311
415
  assert isinstance(cap, _pytest.capture.CaptureManager)
312
416
  cap.resume_global_capture()
313
417
  cap.activate_fixture()
418
+ resource_dict = get_resource_dict(item)
419
+ test_id = f"{item.nodeid}"
420
+ resource_timeout = float(item.config.getini("resource_timeout")) # type: ignore
314
421
  result, exitcode, timed_out = forked_subprocess(
315
- run_subprocess, (item,), timeout, mem_limit, cpu_limit
422
+ run_subprocess,
423
+ (item,),
424
+ timeout,
425
+ mem_limit,
426
+ cpu_limit,
427
+ wait_delta,
428
+ resource_dict,
429
+ test_id,
430
+ resource_timeout,
316
431
  )
317
432
  cap.deactivate_fixture()
318
433
  cap.suspend_global_capture(in_=False)
@@ -396,7 +511,7 @@ def report_process_crash(
396
511
  reason = xfail_marker.kwargs.get("reason", "")
397
512
  rep.wasxfail = ""
398
513
  if reason:
399
- rep.wasxfail = f"reason: {xfail_marker.kwargs.get('reason','')}; "
514
+ rep.wasxfail = f"reason: {xfail_marker.kwargs.get('reason', '')}; "
400
515
  rep.wasxfail += f"pytest-isolate reason: {call.excinfo}"
401
516
 
402
517
  warnings.warn(
@@ -447,23 +562,66 @@ def get_marker(item, marker_name, argname=None, pos=None):
447
562
  return marker
448
563
 
449
564
 
565
+ def get_resource_dict(item):
566
+ """
567
+ Get resource requirements from the resources parameter of the isolate marker.
568
+
569
+ Example: @pytest.mark.isolate(resources={'gpu': 2})
570
+ """
571
+ resources = {}
572
+ isolate_marker = item.get_closest_marker("isolate")
573
+
574
+ if isolate_marker is not None and "resources" in isolate_marker.kwargs:
575
+ resources_param = isolate_marker.kwargs.get("resources", {})
576
+
577
+ # Ensure the resources parameter is a dictionary
578
+ if not isinstance(resources_param, dict):
579
+ warnings.warn(
580
+ f"Invalid resources parameter {resources_param}. Must be a dictionary.",
581
+ RuntimeWarning,
582
+ )
583
+ return resources
584
+
585
+ # Process each resource requirement
586
+ for resource_type, count in resources_param.items():
587
+ try:
588
+ count = int(count)
589
+ if count < 0:
590
+ warnings.warn(
591
+ f"Invalid {resource_type} count: {count}. Must be >= 0.",
592
+ RuntimeWarning,
593
+ )
594
+ continue
595
+ resources[resource_type] = count
596
+ except (ValueError, TypeError):
597
+ warnings.warn(
598
+ f"Invalid {resource_type} count: {count}. Must be an integer.",
599
+ RuntimeWarning,
600
+ )
601
+
602
+ return resources
603
+
604
+
450
605
  @pytest.hookimpl(tryfirst=True)
451
- def pytest_runtest_protocol(item):
606
+ def pytest_runtest_protocol(item: pytest.Item):
452
607
  if item.config.pluginmanager.get_plugin("forked"):
453
608
  return
454
609
  if item.config.pluginmanager.get_plugin("timeout"):
455
610
  return
456
- isolate, timeout, mem_limit, cpu_limit = get_isolation_options(item)
611
+ isolate, timeout, mem_limit, cpu_limit, resource_reqs = get_isolation_options(item)
612
+
613
+ wait_delta = float(item.config.getini("wait_delta")) # type: ignore
457
614
 
458
615
  if (
459
616
  isolate is not None
460
617
  or mem_limit is not None
461
618
  or timeout is not None
462
619
  or cpu_limit is not None
620
+ or resource_reqs
463
621
  ):
464
622
  ihook = item.ihook
465
623
  ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
466
- reports = run_in_subprocess(item, timeout, mem_limit, cpu_limit)
624
+ reports = run_in_subprocess(item, timeout, mem_limit, cpu_limit, wait_delta)
467
625
 
468
626
  for rep in reports:
469
627
  ihook.pytest_runtest_logreport(report=rep)
@@ -550,42 +708,100 @@ def get_isolation_options(item):
550
708
  pass
551
709
  else:
552
710
  isolate = forked
711
+
553
712
  timeout = get_timeout(item)
554
713
  mem_limit = get_memory_limit(item)
555
714
  cpu_limit = get_cpu_limit(item)
556
715
 
557
- return isolate, timeout, mem_limit, cpu_limit
716
+ # Get resources from isolate(resources={'gpu': 2}) syntax
717
+ resource_dict = get_resource_dict(item)
718
+
719
+ # If resources are requested, ensure isolation
720
+ if resource_dict and not isolate:
721
+ isolate = True
722
+
723
+ return isolate, timeout, mem_limit, cpu_limit, resource_dict
558
724
 
559
725
 
560
726
  @pytest.hookimpl(tryfirst=True)
561
- def pytest_terminal_summary(terminalreporter) -> None:
727
+ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None:
562
728
  durations = terminalreporter.config.option.durations
563
729
  durations_min = terminalreporter.config.option.durations_min
564
730
  verbose = terminalreporter.config.getvalue("verbose")
565
- if durations is None:
566
- return
567
731
  tr = terminalreporter
568
- dlist = []
569
- for replist in tr.stats.values():
570
- for rep in replist:
571
- if hasattr(rep, "cpu_usage"):
572
- dlist.append(rep)
573
- if not dlist:
574
- return
575
- dlist.sort(key=lambda x: x.cpu_usage, reverse=True) # type: ignore[no-any-return]
576
- if not durations:
577
- tr.write_sep("=", "highest cpu_usage")
578
- else:
579
- tr.write_sep("=", "highest %s cpu_usage" % durations)
580
- dlist = dlist[:durations]
581
-
582
- for i, rep in enumerate(dlist):
583
- if verbose < 2 and rep.cpu_usage < durations_min:
584
- tr.write_line("")
585
- tr.write_line(
586
- "(%s cpu_usage < %gs hidden. Use -vv to show these durations.)"
587
- % (len(dlist) - i, durations_min)
588
- )
589
- break
590
- if rep.when == "teardown":
591
- tr.write_line(f"{rep.cpu_usage:02.2f}s {rep.nodeid}")
732
+ if durations is not None:
733
+ dlist = []
734
+ for replist in tr.stats.values():
735
+ for rep in replist:
736
+ if hasattr(rep, "cpu_usage"):
737
+ dlist.append(rep)
738
+ if not dlist:
739
+ return
740
+ dlist.sort(key=lambda x: x.cpu_usage, reverse=True) # type: ignore[no-any-return]
741
+ if not durations:
742
+ tr.write_sep("=", "highest cpu_usage")
743
+ else:
744
+ tr.write_sep("=", "highest %s cpu_usage" % durations)
745
+ dlist = dlist[:durations]
746
+
747
+ for i, rep in enumerate(dlist):
748
+ if verbose < 2 and rep.cpu_usage < durations_min:
749
+ tr.write_line("")
750
+ tr.write_line(
751
+ "(%s cpu_usage < %gs hidden. Use -vv to show these durations.)"
752
+ % (len(dlist) - i, durations_min)
753
+ )
754
+ break
755
+ if rep.when == "teardown":
756
+ tr.write_line(f"{rep.cpu_usage:02.2f}s {rep.nodeid}")
757
+
758
+ if config.getoption("timeline"):
759
+ events = []
760
+ for replist in tr.stats.values():
761
+ rep: pytest.TestReport
762
+
763
+ for rep in replist:
764
+ if not isinstance(rep, pytest.TestReport) or rep.when != "call":
765
+ continue
766
+ props = dict(getattr(rep, "user_properties", ()))
767
+ events.extend(
768
+ create_event(
769
+ props.get("worker_id", "master"),
770
+ rep.nodeid,
771
+ rep.outcome,
772
+ rep.start,
773
+ rep.stop,
774
+ )
775
+ )
776
+ filename = config.getoption("timeline_file")
777
+ os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
778
+ json.dump(events, open(filename, "w"))
779
+
780
+
781
+ @pytest.hookimpl(tryfirst=False, hookwrapper=True)
782
+ def pytest_runtest_call(item: pytest.Item):
783
+ yield
784
+ item.user_properties.append(
785
+ ("worker_id", os.getenv("PYTEST_XDIST_WORKER", "master"))
786
+ )
787
+
788
+
789
+ def create_event(worker_name, test_name, category, start_time, end_time, **kwargs):
790
+ start_event = {
791
+ "name": test_name.rsplit("/")[-1],
792
+ "cat": category,
793
+ "ph": "B",
794
+ "pid": worker_name,
795
+ "tid": 0,
796
+ "ts": start_time * (1000**2),
797
+ "args": kwargs,
798
+ }
799
+ end_event = {
800
+ "name": test_name.rsplit("/")[-1],
801
+ "cat": "pipeline",
802
+ "ph": "E",
803
+ "pid": worker_name,
804
+ "tid": 0,
805
+ "ts": end_time * (1000**2),
806
+ }
807
+ return start_event, end_event
@@ -0,0 +1,148 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import time
5
+ import warnings
6
+ from contextlib import contextmanager
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, List, Optional
10
+
11
+ import filelock
12
+
13
+ # Constants
14
+
15
+ DEFAULT_STATE_FILE = Path(tempfile.gettempdir()) / "pytest_isolate_resources.json"
16
+ DEFAULT_WAIT_TIMEOUT = 300 # 5 minutes
17
+ POLL_INTERVAL = 0.1 # 1 second
18
+
19
+
20
+ @contextmanager
21
+ def lock_resource_file():
22
+ with filelock.FileLock(str(DEFAULT_STATE_FILE)+'.lock', timeout=4) as lock:
23
+ yield lock
24
+
25
+
26
+ @dataclass
27
+ class Resource:
28
+ env_variable: str
29
+ available: List[int]
30
+ allocated: dict[str, List[int]]
31
+
32
+ def allocate(self, test_id: str, count: int) -> List[int]:
33
+ """Allocate resources for a test."""
34
+ if len(self.available) < count:
35
+ return []
36
+
37
+ allocated = self.available[:count]
38
+ self.allocated[test_id] = allocated
39
+ self.available = self.available[count:]
40
+ return allocated
41
+
42
+ def release(self, test_id: str) -> None:
43
+ """Release resources for a test."""
44
+ if test_id in self.allocated:
45
+ released = self.allocated.pop(test_id)
46
+ self.available.extend(released)
47
+ else:
48
+ raise ValueError(f"Test ID {test_id} not found in allocated resources.")
49
+
50
+
51
+ @dataclass
52
+ class StateData:
53
+ resources: dict[str, Resource]
54
+
55
+ @classmethod
56
+ def get_instance(cls) -> "StateData":
57
+ if not DEFAULT_STATE_FILE.exists():
58
+ data = {}
59
+ try:
60
+ with open(DEFAULT_STATE_FILE, "r") as f:
61
+ data = json.load(f)
62
+ except (json.JSONDecodeError, IOError):
63
+ data = {}
64
+ return StateData(
65
+ resources={name: Resource(**res) for name, res in data.items()}
66
+ )
67
+
68
+ def save(self) -> None:
69
+ with open(DEFAULT_STATE_FILE, "w") as f:
70
+ json.dump({name: asdict(res) for name, res in self.resources.items()}, f)
71
+
72
+
73
+ def clean_resources() -> None:
74
+ """Clean up resources by removing lock and state files."""
75
+ if DEFAULT_STATE_FILE.exists():
76
+ os.remove(DEFAULT_STATE_FILE)
77
+
78
+
79
+ def register_resource_provider(
80
+ resource_type: str, env_variable: str, provider_func: Callable[[], List[int]]
81
+ ) -> None:
82
+ with lock_resource_file():
83
+ existing = provider_func()
84
+ state = StateData.get_instance()
85
+ if resource_type in state.resources:
86
+ warnings.warn(
87
+ f"Resource type {resource_type} already registered. Overwriting."
88
+ )
89
+ state.resources[resource_type] = Resource(
90
+ env_variable=env_variable,
91
+ available=existing,
92
+ allocated={},
93
+ )
94
+ state.save()
95
+
96
+
97
+ def parse_resource_list(env_value: Optional[str]) -> List[int]:
98
+ """Parse a comma-separated list of resource IDs from an environment variable."""
99
+ if not env_value:
100
+ return []
101
+ try:
102
+ vals= [int(x.strip()) for x in env_value.split(",") if x.strip()]
103
+ vals = [x for x in vals if x >= 0]
104
+ return vals
105
+ except ValueError:
106
+ return []
107
+
108
+
109
+ def setup_resource_environment(
110
+ test_id: str,
111
+ count: int,
112
+ resource_type: str,
113
+ wait_timeout: Optional[float] = None,
114
+ ) -> Optional[List[int]]:
115
+ def try_allocate() -> Optional[List[int]]:
116
+ with lock_resource_file():
117
+ state = StateData.get_instance()
118
+ if resource_type not in state.resources:
119
+ raise ValueError(f"Resource type {resource_type} not registered.")
120
+
121
+ resource = state.resources[resource_type]
122
+ allocated = resource.allocate(test_id, count)
123
+ if allocated:
124
+ os.environ[resource.env_variable] = ",".join(map(str, allocated))
125
+ state.save()
126
+ return allocated
127
+ return []
128
+
129
+ timeout = wait_timeout or DEFAULT_WAIT_TIMEOUT
130
+ start_time = time.time()
131
+ while time.time() - start_time < timeout:
132
+ allocated = try_allocate()
133
+ if allocated:
134
+ return allocated
135
+ time.sleep(POLL_INTERVAL)
136
+ raise TimeoutError(f"Timeout while waiting for resources of type {resource_type}.")
137
+
138
+
139
+ def cleanup_resource_environment(test_id: str, resource_type: str) -> None:
140
+ with lock_resource_file():
141
+ state = StateData.get_instance()
142
+ if resource_type not in state.resources:
143
+ raise ValueError(f"Resource type {resource_type} not registered.")
144
+
145
+ resource = state.resources[resource_type]
146
+ resource.release(test_id)
147
+ os.environ.pop(resource.env_variable, None)
148
+ state.save()
@@ -74,7 +74,13 @@ def test_warn():
74
74
  @pytest.mark.isolate(cpu_limit=1)
75
75
  def test_isolate_cpu():
76
76
  import numpy as np
77
+
77
78
  # hog CPU:
78
- x=0
79
+ x = 0
79
80
  for i in range(100):
80
- x+=(np.random.rand(1000,1000)@np.random.rand(1000,1000)).sum()
81
+ x += (np.random.rand(1000, 1000) @ np.random.rand(1000, 1000)).sum()
82
+
83
+
84
+ def test_slow():
85
+ sleep(0.1)
86
+ pass
@@ -0,0 +1,81 @@
1
+ import os
2
+ import random
3
+ import tempfile
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from time import sleep
7
+ from unittest import mock
8
+
9
+ import filelock
10
+ import pytest
11
+
12
+ from pytest_isolate.plugin import allocate_resources
13
+ from pytest_isolate.resource_management import (
14
+ parse_resource_list,
15
+ register_resource_provider,
16
+ )
17
+
18
+
19
+ @pytest.fixture
20
+ def temp_resource_files():
21
+ """Create temporary files for resource manager testing"""
22
+ with tempfile.TemporaryDirectory() as tmpdir:
23
+ lock_file = Path(tmpdir) / "resources.lock"
24
+ state_file = Path(tmpdir) / "resources.json"
25
+ yield lock_file, state_file
26
+
27
+
28
+ @pytest.fixture
29
+ def mock_pynvml():
30
+ """Mock pynvml module for testing"""
31
+ pynvml_mock = mock.MagicMock()
32
+ pynvml_mock.nvmlDeviceGetCount.return_value = 4
33
+
34
+ # Mock the import so it returns our mock when imported in plugin.py
35
+ with mock.patch.dict("sys.modules", {"pynvml": pynvml_mock}):
36
+ yield pynvml_mock
37
+
38
+
39
+ def test_parse_resource_list():
40
+ """Test parsing resources from environment variables."""
41
+ # Test empty input
42
+ assert parse_resource_list(None) == []
43
+ assert parse_resource_list("") == []
44
+
45
+ # Test simple cases
46
+ assert parse_resource_list("0") == [0]
47
+ assert parse_resource_list("0,1,2") == [0, 1, 2]
48
+
49
+ # Test with spaces
50
+ assert parse_resource_list(" 0, 1, 2 ") == [0, 1, 2]
51
+
52
+ # Test with invalid values - no warning in new implementation
53
+ assert parse_resource_list("0,a,2") == []
54
+
55
+
56
+ def test_allocate_resources():
57
+ with allocate_resources({"gpu": 1}, "foo", 100) as allocated:
58
+ assert allocated["gpu"] == [0]
59
+ assert os.environ.get("CUDA_VISIBLE_DEVICES") == "0"
60
+
61
+
62
+
63
+ @pytest.mark.isolate(resources={"gpu": 2}, timeout=10)
64
+ @pytest.mark.parametrize("dummy", [1] * 2)
65
+ def test_gpu_marker_2(dummy):
66
+ sleep(0.3)
67
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 2
68
+
69
+
70
+ @pytest.mark.isolate(resources={"gpu": 1}, timeout=10)
71
+ @pytest.mark.parametrize("dummy", [1] * 2)
72
+ def test_gpu_marker_1(dummy):
73
+ sleep(0.3)
74
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 1
75
+
76
+
77
+ @pytest.mark.xfail
78
+ @pytest.mark.isolate(resources={"gpu": 7}, timeout=4)
79
+ def test_gpu_marker_7():
80
+ sleep(0.3)
81
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 7
@@ -2,7 +2,7 @@
2
2
  [tox]
3
3
  requires =
4
4
  tox >=4
5
- envlist = py{37,311}
5
+ envlist = py{39,312}
6
6
  isolated_build = True
7
7
 
8
8
  [testenv]
@@ -11,8 +11,10 @@ deps =
11
11
  numpy
12
12
  pytest-xdist
13
13
  pytest-sugar
14
-
15
- commands = pytest --isolate --import-mode importlib {posargs:tests}
14
+ pytest-mock
15
+ setenv =
16
+ CUDA_VISIBLE_DEVICES = 0,1
17
+ commands = pytest --isolate --import-mode importlib {posargs:tests} --timeline
16
18
 
17
19
  [testenv:lint]
18
20
  skip_install = true
@@ -21,5 +23,5 @@ commands = ruff check {postargs:.}
21
23
 
22
24
  [gh]
23
25
  python =
24
- 3.7 = py37
25
- 3.11 = py311
26
+ 3.9 = py39
27
+ 3.12 = py312
@@ -1 +0,0 @@
1
- __version__ = '0.0.3'