pytest-isolate 0.0.4__tar.gz → 0.0.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-isolate
3
- Version: 0.0.4
3
+ Version: 0.0.9
4
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
@@ -18,8 +18,11 @@ 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
@@ -40,6 +43,7 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
40
43
  * Add Timeout to a forked test
41
44
  * Limit memory used by test
42
45
  * Limit CPU time used by test
46
+ * Manage GPU resources with CUDA_VISIBLE_DEVICES
43
47
  * Plays nice with pytest-xdist
44
48
  * Shows warnings, even with xdist!
45
49
  * *Create visual timeline of test execution (isolated or not)*
@@ -48,12 +52,22 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
48
52
 
49
53
  * pytest
50
54
 
55
+ ### Optional Dependencies
56
+
57
+ For GPU resource management:
58
+
59
+ * pynvml (optional, for automatic GPU detection)
60
+
51
61
  ## Installation
52
62
 
53
63
  You can install "pytest-isolate" via `pip` from `PyPI`
54
64
 
55
65
  pip install pytest-isolate
56
66
 
67
+ For GPU resource management support:
68
+
69
+ pip install pytest-isolate[gpu]
70
+
57
71
  ## Usage
58
72
 
59
73
  pytest --isolate
@@ -84,7 +98,9 @@ they will take precedence. Uninstall them to use `pytest-isolate`.
84
98
 
85
99
  Unlike `pytest-timeout`, timeout in `pytest-isolate` is implemented by forking the test to a separate subprocess, and setting timeout for that subprocess.
86
100
 
87
- 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:
88
104
 
89
105
  ```python
90
106
  @pytest.mark.isolate(timeout=10, mem_limit=10**6, cpu_limit=10)
@@ -92,7 +108,19 @@ def test_something():
92
108
  pass
93
109
  ```
94
110
 
95
- 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:
96
124
 
97
125
  ```toml
98
126
  [tool.pytest.ini_options]
@@ -101,6 +129,15 @@ isolate_mem_limit=1000000
101
129
  isolate_cpu_limit=10
102
130
  ```
103
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
+
104
141
  ## Contributing
105
142
 
106
143
  Contributions are very welcome. Tests can be run with `tox`, please ensure
@@ -16,6 +16,7 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
16
16
  * Add Timeout to a forked test
17
17
  * Limit memory used by test
18
18
  * Limit CPU time used by test
19
+ * Manage GPU resources with CUDA_VISIBLE_DEVICES
19
20
  * Plays nice with pytest-xdist
20
21
  * Shows warnings, even with xdist!
21
22
  * *Create visual timeline of test execution (isolated or not)*
@@ -24,12 +25,22 @@ This pytest plugin was generated with Cookiecutter along with `@hackebrot`'s `co
24
25
 
25
26
  * pytest
26
27
 
28
+ ### Optional Dependencies
29
+
30
+ For GPU resource management:
31
+
32
+ * pynvml (optional, for automatic GPU detection)
33
+
27
34
  ## Installation
28
35
 
29
36
  You can install "pytest-isolate" via `pip` from `PyPI`
30
37
 
31
38
  pip install pytest-isolate
32
39
 
40
+ For GPU resource management support:
41
+
42
+ pip install pytest-isolate[gpu]
43
+
33
44
  ## Usage
34
45
 
35
46
  pytest --isolate
@@ -60,7 +71,9 @@ they will take precedence. Uninstall them to use `pytest-isolate`.
60
71
 
61
72
  Unlike `pytest-timeout`, timeout in `pytest-isolate` is implemented by forking the test to a separate subprocess, and setting timeout for that subprocess.
62
73
 
63
- 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:
64
77
 
65
78
  ```python
66
79
  @pytest.mark.isolate(timeout=10, mem_limit=10**6, cpu_limit=10)
@@ -68,7 +81,19 @@ def test_something():
68
81
  pass
69
82
  ```
70
83
 
71
- 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:
72
97
 
73
98
  ```toml
74
99
  [tool.pytest.ini_options]
@@ -77,6 +102,15 @@ isolate_mem_limit=1000000
77
102
  isolate_cpu_limit=10
78
103
  ```
79
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
+
80
114
  ## Contributing
81
115
 
82
116
  Contributions are very welcome. Tests can be run with `tox`, please ensure
@@ -20,9 +20,12 @@ classifiers = [
20
20
  "Programming Language :: Python :: Implementation :: CPython",
21
21
  "Programming Language :: Python :: Implementation :: PyPy",
22
22
  ]
23
- dependencies = ["dill", "tblib", "pytest"]
23
+ dependencies = ["dill", "tblib", "pytest","filelock"]
24
24
  dynamic = ["version"]
25
25
 
26
+ [project.optional-dependencies]
27
+ gpu = ["pynvml"]
28
+
26
29
  [project.urls]
27
30
  Documentation = "https://github.com/gilfree/pytest-isolate#readme"
28
31
  Issues = "https://github.com/gilfree/pytest-isolate/issues"
@@ -0,0 +1 @@
1
+ __version__ = "0.0.9"
@@ -40,13 +40,52 @@ try:
40
40
  except ImportError:
41
41
  pass
42
42
 
43
+ from pytest_isolate.tracing import create_event
44
+ from pytest_isolate.resource_management import (
45
+ clean_resources,
46
+ cleanup_resource_environment,
47
+ get_resource_events,
48
+ parse_resource_list,
49
+ register_resource_provider,
50
+ setup_resource_environment,
51
+ )
52
+
53
+
54
+ def get_available_gpus() -> List[int]:
55
+ # Check if CUDA_VISIBLE_DEVICES is already set
56
+ cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
57
+ if cuda_visible_devices is not None:
58
+ return parse_resource_list(cuda_visible_devices)
59
+
60
+ # If not set and pynvml is available, get all GPUs
61
+ try:
62
+ import pynvml
63
+
64
+ pynvml.nvmlInit()
65
+ count = pynvml.nvmlDeviceGetCount()
66
+ resources = list(range(count))
67
+ pynvml.nvmlShutdown()
68
+ return resources
69
+ except Exception:
70
+ warnings.warn("Failed to get GPU count using pynvml", RuntimeWarning)
71
+
72
+ return []
73
+
43
74
 
44
75
  def forked_subprocess(
45
- target, args=(), timeout=None, memlimt=None, cpulimit=None
76
+ target,
77
+ args=(),
78
+ timeout=None,
79
+ memlimt=None,
80
+ cpulimit=None,
81
+ wait_delta=0.05,
82
+ resource_dict=None,
83
+ test_id=None,
84
+ resource_timeout=None,
46
85
  ) -> Tuple[Any, int, bool]:
47
- sub = ForkedSubprocess()
86
+ sub = ForkedSubprocess(wait_delta=wait_delta)
48
87
  exitcode, timed_out, result = sub.run_in_subprocess(
49
- timeout, memlimt, cpulimit, target, args
88
+ timeout, memlimt, cpulimit, resource_dict, test_id, resource_timeout, target, args
50
89
  )
51
90
  return result, exitcode, timed_out
52
91
 
@@ -73,7 +112,8 @@ def limits(max_mem, max_cpu):
73
112
 
74
113
 
75
114
  class ForkedSubprocess:
76
- def __init__(self) -> None:
115
+ def __init__(self, wait_delta=0.05) -> None:
116
+ self.wait_delta = wait_delta
77
117
  self.r_out, self.w_out = os.pipe()
78
118
  self.r_err, self.w_err = os.pipe()
79
119
  fcntl.fcntl(self.r_err, fcntl.F_SETFL, os.O_NONBLOCK)
@@ -103,7 +143,17 @@ class ForkedSubprocess:
103
143
  os.close(self.w_out)
104
144
  os.close(self.w_err)
105
145
 
106
- def run_in_subprocess(self, timeout, memlimit, cpulimit, target, args=()):
146
+ def run_in_subprocess(
147
+ self,
148
+ timeout,
149
+ memlimit,
150
+ cpulimit,
151
+ resource_dict,
152
+ test_id,
153
+ resource_timeout,
154
+ target,
155
+ args=(),
156
+ ):
107
157
  ctx = mp.get_context("fork")
108
158
  q = ctx.Queue()
109
159
  timed_out = None
@@ -113,7 +163,8 @@ class ForkedSubprocess:
113
163
  self.child_redirect_streams()
114
164
  try:
115
165
  with limits(memlimit, cpulimit):
116
- q.put(dill.dumps(target(*args)))
166
+ with allocate_resources(resource_dict, test_id, resource_timeout):
167
+ q.put(dill.dumps(target(*args)))
117
168
  except BaseException as e:
118
169
  q.put(dill.dumps(e))
119
170
  sys.stdout.flush()
@@ -123,16 +174,17 @@ class ForkedSubprocess:
123
174
  p = ctx.Process(target=run_subprocess)
124
175
  p.start()
125
176
  self.parent_open_streams()
126
- time_left = timeout or 1
177
+ delta = self.wait_delta
178
+ time_left = timeout or delta
127
179
  result = None
128
180
  while time_left > 0:
129
181
  try:
130
- result = dill.loads(q.get(block=True, timeout=min(1, time_left)))
182
+ result = dill.loads(q.get(block=True, timeout=min(delta, time_left)))
131
183
  except Empty:
132
184
  if not p.is_alive():
133
185
  break
134
186
  if timeout is not None:
135
- time_left = time_left - 1
187
+ time_left = time_left - delta
136
188
  except Exception as e:
137
189
  result = e
138
190
  break
@@ -163,7 +215,7 @@ def pytest_load_initial_conftests(early_config, parser, args):
163
215
  )
164
216
  early_config.addinivalue_line(
165
217
  "markers",
166
- "isolate: Always isolate this test.",
218
+ "isolate: Always isolate this test, possibly with resources.",
167
219
  )
168
220
  early_config.addinivalue_line(
169
221
  "markers",
@@ -209,7 +261,18 @@ def pytest_addoption(parser):
209
261
  parser.addini(
210
262
  "isolate_cpu_limit", "Default cpu limit for isolated tests", type="string"
211
263
  )
212
-
264
+ parser.addini(
265
+ "wait_delta",
266
+ "Delta between subprocess queue pollings",
267
+ type="string",
268
+ default="0.05",
269
+ )
270
+ parser.addini(
271
+ "resource_timeout",
272
+ "Timeout for resource allocation",
273
+ type="string",
274
+ default="300",
275
+ )
213
276
  parser.addoption(
214
277
  "--timeline",
215
278
  dest="timeline",
@@ -228,6 +291,7 @@ def pytest_addoption(parser):
228
291
  parser.addini("timeline_file", "timeline file", type="string")
229
292
 
230
293
 
294
+
231
295
  @pytest.hookimpl(trylast=True)
232
296
  def pytest_configure(config):
233
297
  if config.pluginmanager.get_plugin("forked"):
@@ -253,6 +317,8 @@ def pytest_configure(config):
253
317
  "markers",
254
318
  "timeout: Run this tests in a separate, forked, process with timeout",
255
319
  )
320
+ clean_resources()
321
+ register_resource_provider("gpu", "CUDA_VISIBLE_DEVICES", get_available_gpus)
256
322
 
257
323
 
258
324
  # Taken from pytest 7.2:
@@ -314,23 +380,56 @@ def run_subprocess(item: pytest.Item):
314
380
  config=item.config, report=report
315
381
  )
316
382
  )
383
+
317
384
  return s_reports, warnings
318
385
  except BaseException as e:
319
386
  return e, None
320
387
 
321
388
 
389
+ @contextmanager
390
+ def allocate_resources(resource_dict, test_id, timeout):
391
+ resource_dict = resource_dict or {}
392
+ allocated_resources = {}
393
+ for resource_type, count in resource_dict.items():
394
+ # Allocate resources
395
+ resource_ids = setup_resource_environment(
396
+ test_id=f"{test_id}_{resource_type}",
397
+ count=count,
398
+ resource_type=resource_type,
399
+ wait_timeout=timeout,
400
+ )
401
+ allocated_resources[resource_type] = resource_ids
402
+ try:
403
+ yield allocated_resources
404
+ finally:
405
+ for res_type, _ in allocated_resources.items():
406
+ cleanup_resource_environment(f"{test_id}_{res_type}", res_type)
407
+
408
+
322
409
  def run_in_subprocess(
323
410
  item: pytest.Item,
324
411
  timeout: Optional[float],
325
412
  mem_limit: Optional[int],
326
413
  cpu_limit: Optional[int],
414
+ wait_delta: float,
327
415
  ) -> List[pytest.TestReport]:
328
416
  cap = item.config.pluginmanager.getplugin("capturemanager")
329
417
  assert isinstance(cap, _pytest.capture.CaptureManager)
330
418
  cap.resume_global_capture()
331
419
  cap.activate_fixture()
420
+ resource_dict = get_resource_dict(item)
421
+ test_id = f"{item.nodeid}"
422
+ resource_timeout = float(item.config.getini("resource_timeout")) # type: ignore
332
423
  result, exitcode, timed_out = forked_subprocess(
333
- run_subprocess, (item,), timeout, mem_limit, cpu_limit
424
+ run_subprocess,
425
+ (item,),
426
+ timeout,
427
+ mem_limit,
428
+ cpu_limit,
429
+ wait_delta,
430
+ resource_dict,
431
+ test_id,
432
+ resource_timeout,
334
433
  )
335
434
  cap.deactivate_fixture()
336
435
  cap.suspend_global_capture(in_=False)
@@ -414,7 +513,7 @@ def report_process_crash(
414
513
  reason = xfail_marker.kwargs.get("reason", "")
415
514
  rep.wasxfail = ""
416
515
  if reason:
417
- rep.wasxfail = f"reason: {xfail_marker.kwargs.get('reason','')}; "
516
+ rep.wasxfail = f"reason: {xfail_marker.kwargs.get('reason', '')}; "
418
517
  rep.wasxfail += f"pytest-isolate reason: {call.excinfo}"
419
518
 
420
519
  warnings.warn(
@@ -465,23 +564,66 @@ def get_marker(item, marker_name, argname=None, pos=None):
465
564
  return marker
466
565
 
467
566
 
567
+ def get_resource_dict(item):
568
+ """
569
+ Get resource requirements from the resources parameter of the isolate marker.
570
+
571
+ Example: @pytest.mark.isolate(resources={'gpu': 2})
572
+ """
573
+ resources = {}
574
+ isolate_marker = item.get_closest_marker("isolate")
575
+
576
+ if isolate_marker is not None and "resources" in isolate_marker.kwargs:
577
+ resources_param = isolate_marker.kwargs.get("resources", {})
578
+
579
+ # Ensure the resources parameter is a dictionary
580
+ if not isinstance(resources_param, dict):
581
+ warnings.warn(
582
+ f"Invalid resources parameter {resources_param}. Must be a dictionary.",
583
+ RuntimeWarning,
584
+ )
585
+ return resources
586
+
587
+ # Process each resource requirement
588
+ for resource_type, count in resources_param.items():
589
+ try:
590
+ count = int(count)
591
+ if count < 0:
592
+ warnings.warn(
593
+ f"Invalid {resource_type} count: {count}. Must be >= 0.",
594
+ RuntimeWarning,
595
+ )
596
+ continue
597
+ resources[resource_type] = count
598
+ except (ValueError, TypeError):
599
+ warnings.warn(
600
+ f"Invalid {resource_type} count: {count}. Must be an integer.",
601
+ RuntimeWarning,
602
+ )
603
+
604
+ return resources
605
+
606
+
468
607
  @pytest.hookimpl(tryfirst=True)
469
- def pytest_runtest_protocol(item):
608
+ def pytest_runtest_protocol(item: pytest.Item):
470
609
  if item.config.pluginmanager.get_plugin("forked"):
471
610
  return
472
611
  if item.config.pluginmanager.get_plugin("timeout"):
473
612
  return
474
- isolate, timeout, mem_limit, cpu_limit = get_isolation_options(item)
613
+ isolate, timeout, mem_limit, cpu_limit, resource_reqs = get_isolation_options(item)
614
+
615
+ wait_delta = float(item.config.getini("wait_delta")) # type: ignore
475
616
 
476
617
  if (
477
618
  isolate is not None
478
619
  or mem_limit is not None
479
620
  or timeout is not None
480
621
  or cpu_limit is not None
622
+ or resource_reqs
481
623
  ):
482
624
  ihook = item.ihook
483
625
  ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
484
- reports = run_in_subprocess(item, timeout, mem_limit, cpu_limit)
626
+ reports = run_in_subprocess(item, timeout, mem_limit, cpu_limit, wait_delta)
485
627
 
486
628
  for rep in reports:
487
629
  ihook.pytest_runtest_logreport(report=rep)
@@ -568,11 +710,19 @@ def get_isolation_options(item):
568
710
  pass
569
711
  else:
570
712
  isolate = forked
713
+
571
714
  timeout = get_timeout(item)
572
715
  mem_limit = get_memory_limit(item)
573
716
  cpu_limit = get_cpu_limit(item)
574
717
 
575
- return isolate, timeout, mem_limit, cpu_limit
718
+ # Get resources from isolate(resources={'gpu': 2}) syntax
719
+ resource_dict = get_resource_dict(item)
720
+
721
+ # If resources are requested, ensure isolation
722
+ if resource_dict and not isolate:
723
+ isolate = True
724
+
725
+ return isolate, timeout, mem_limit, cpu_limit, resource_dict
576
726
 
577
727
 
578
728
  @pytest.hookimpl(tryfirst=True)
@@ -628,7 +778,8 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None:
628
778
  filename = config.getoption("timeline_file")
629
779
  os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
630
780
  json.dump(events, open(filename, "w"))
631
-
781
+ filename= filename.replace('.json', '.resources.json')
782
+ json.dump(get_resource_events(), open(filename, "w"))
632
783
 
633
784
  @pytest.hookimpl(tryfirst=False, hookwrapper=True)
634
785
  def pytest_runtest_call(item: pytest.Item):
@@ -638,22 +789,3 @@ def pytest_runtest_call(item: pytest.Item):
638
789
  )
639
790
 
640
791
 
641
- def create_event(worker_name, test_name, category, start_time, end_time, **kwargs):
642
- start_event = {
643
- "name": test_name.rsplit("/")[-1],
644
- "cat": category,
645
- "ph": "B",
646
- "pid": worker_name,
647
- "tid": 0,
648
- "ts": start_time * (1000**2),
649
- "args": kwargs,
650
- }
651
- end_event = {
652
- "name": test_name.rsplit("/")[-1],
653
- "cat": "pipeline",
654
- "ph": "E",
655
- "pid": worker_name,
656
- "tid": 0,
657
- "ts": end_time * (1000**2),
658
- }
659
- return start_event, end_event
@@ -0,0 +1,229 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import time
5
+ import warnings
6
+ from contextlib import contextmanager
7
+ from copy import deepcopy
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Callable, List, Optional
11
+
12
+ import filelock
13
+
14
+ from pytest_isolate.tracing import create_event
15
+
16
+ # Constants
17
+
18
+ DEFAULT_STATE_FILE = Path(tempfile.gettempdir()) / "pytest_isolate_resources.json"
19
+ DEFAULT_EVENTS_FILE = Path(str(DEFAULT_STATE_FILE).replace(".json", "_events.json"))
20
+ DEFAULT_WAIT_TIMEOUT = 300 # 5 minutes
21
+ POLL_INTERVAL = 0.1 # 1 second
22
+
23
+
24
+ @contextmanager
25
+ def lock_resource_file():
26
+ with filelock.FileLock(str(DEFAULT_STATE_FILE) + ".lock", timeout=4) as lock:
27
+ yield lock
28
+
29
+
30
+ @dataclass
31
+ class Resource:
32
+ env_variable: str
33
+ available: List[int]
34
+ allocated: dict[str, List[int]]
35
+
36
+ def allocate(self, test_id: str, count: int) -> List[int]:
37
+ """Allocate resources for a test."""
38
+ if len(self.available) < count:
39
+ return []
40
+
41
+ allocated = self.available[:count]
42
+ self.allocated[test_id] = allocated
43
+ self.available = self.available[count:]
44
+ return allocated
45
+
46
+ def release(self, test_id: str) -> None:
47
+ """Release resources for a test."""
48
+ if test_id in self.allocated:
49
+ released = self.allocated.pop(test_id)
50
+ self.available.extend(released)
51
+ else:
52
+ raise ValueError(f"Test ID {test_id} not found in allocated resources.")
53
+
54
+
55
+ @dataclass
56
+ class StateData:
57
+ resources: dict[str, Resource]
58
+
59
+ @classmethod
60
+ def get_instance(cls) -> "StateData":
61
+ if not DEFAULT_STATE_FILE.exists():
62
+ data = {}
63
+ try:
64
+ with open(DEFAULT_STATE_FILE, "r") as f:
65
+ data = json.load(f)
66
+ except (json.JSONDecodeError, IOError):
67
+ data = {}
68
+ return StateData(
69
+ resources={name: Resource(**res) for name, res in data.items()}
70
+ )
71
+
72
+ def save(self) -> None:
73
+ with open(DEFAULT_STATE_FILE, "w") as f:
74
+ json.dump({name: asdict(res) for name, res in self.resources.items()}, f)
75
+
76
+
77
+ def clean_resources() -> None:
78
+ """Clean up resources by removing lock and state files."""
79
+ if DEFAULT_STATE_FILE.exists():
80
+ os.remove(DEFAULT_STATE_FILE)
81
+ if DEFAULT_EVENTS_FILE.exists():
82
+ os.remove(DEFAULT_EVENTS_FILE)
83
+ if DEFAULT_STATE_FILE.with_suffix(".lock").exists():
84
+ os.remove(DEFAULT_STATE_FILE.with_suffix(".lock"))
85
+
86
+
87
+ def register_resource_provider(
88
+ resource_type: str, env_variable: str, provider_func: Callable[[], List[int]]
89
+ ) -> None:
90
+ with lock_resource_file():
91
+ existing = provider_func()
92
+ state = StateData.get_instance()
93
+ if resource_type in state.resources:
94
+ warnings.warn(
95
+ f"Resource type {resource_type} already registered. Overwriting."
96
+ )
97
+ state.resources[resource_type] = Resource(
98
+ env_variable=env_variable,
99
+ available=existing,
100
+ allocated={},
101
+ )
102
+ state.save()
103
+
104
+
105
+ def parse_resource_list(env_value: Optional[str]) -> List[int]:
106
+ """Parse a comma-separated list of resource IDs from an environment variable."""
107
+ if not env_value:
108
+ return []
109
+ try:
110
+ vals = [int(x.strip()) for x in env_value.split(",") if x.strip()]
111
+ vals = [x for x in vals if x >= 0]
112
+ return vals
113
+ except ValueError:
114
+ return []
115
+
116
+
117
+ def log_resource_allocation(
118
+ allocated,
119
+ resource_type: str,
120
+ test_id: str,
121
+ start_time=None,
122
+ end_time=None,
123
+ file=None,
124
+ ) -> None:
125
+ file = str(file or DEFAULT_EVENTS_FILE)
126
+ folder = os.path.dirname(file)
127
+ if folder and not os.path.exists(folder):
128
+ os.makedirs(folder, exist_ok=True)
129
+ if not os.path.exists(file):
130
+ resource_events = {}
131
+ else:
132
+ with open(file, "r") as f:
133
+ resource_events = json.load(f)
134
+ if test_id not in resource_events:
135
+ resource_events[test_id] = dict(
136
+ allocated=allocated,
137
+ worker_name=resource_type,
138
+ test_name=test_id,
139
+ category="resource_allocation",
140
+ )
141
+ if start_time is not None:
142
+ resource_events[test_id]["start_time"] = start_time
143
+ if end_time is not None:
144
+ resource_events[test_id]["end_time"] = end_time
145
+ with open(file, "w") as f:
146
+ json.dump(resource_events, f)
147
+
148
+
149
+ def setup_resource_environment(
150
+ test_id: str,
151
+ count: int,
152
+ resource_type: str,
153
+ wait_timeout: Optional[float] = None,
154
+ ) -> Optional[List[int]]:
155
+ def try_allocate() -> Optional[List[int]]:
156
+ with lock_resource_file():
157
+ state = StateData.get_instance()
158
+ if resource_type not in state.resources:
159
+ raise ValueError(f"Resource type {resource_type} not registered.")
160
+
161
+ resource = state.resources[resource_type]
162
+ allocated = resource.allocate(test_id, count)
163
+ if allocated:
164
+ os.environ[resource.env_variable] = ",".join(map(str, allocated))
165
+ state.save()
166
+ log_resource_allocation(
167
+ resource.allocated.get(test_id),
168
+ resource_type,
169
+ test_id,
170
+ start_time=time.time(),
171
+ )
172
+ return allocated
173
+ return []
174
+
175
+ timeout = wait_timeout or DEFAULT_WAIT_TIMEOUT
176
+ start_time = time.time()
177
+ while time.time() - start_time < timeout:
178
+ allocated = try_allocate()
179
+ if allocated:
180
+ return allocated
181
+ time.sleep(POLL_INTERVAL)
182
+ raise TimeoutError(f"Timeout while waiting for resources of type {resource_type}.")
183
+
184
+
185
+ def cleanup_resource_environment(test_id: str, resource_type: str) -> None:
186
+ with lock_resource_file():
187
+ state = StateData.get_instance()
188
+ if resource_type not in state.resources:
189
+ raise ValueError(f"Resource type {resource_type} not registered.")
190
+
191
+ resource = state.resources[resource_type]
192
+ allocated = resource.allocated.get(test_id)
193
+ if allocated:
194
+ log_resource_allocation(
195
+ resource.allocated.get(test_id),
196
+ resource_type,
197
+ test_id,
198
+ end_time=time.time(),
199
+ )
200
+ resource.release(test_id)
201
+
202
+ os.environ.pop(resource.env_variable, None)
203
+ state.save()
204
+
205
+
206
+ def get_resource_events(file=None) -> list[dict]:
207
+ # Convert the resource events to the format used in tracing.py,
208
+ # by merging the start and end events.
209
+ file = Path(file or str(DEFAULT_EVENTS_FILE))
210
+ if not file.exists():
211
+ return []
212
+ resource_events = json.load(open(file))
213
+ events = []
214
+ for test_id, event in resource_events.items():
215
+ event_args = dict(**event)
216
+ allocated = event_args.pop("allocated", None)
217
+ for resource_id in allocated:
218
+ resource_event = deepcopy(event_args)
219
+ resource_event["worker_name"] = (
220
+ f"{resource_event['worker_name']}_{resource_id}"
221
+ )
222
+ if "end_time" not in resource_event:
223
+ resource_event["end_time"] = time.time()
224
+ if "start_time" not in resource_event:
225
+ raise ValueError(
226
+ f"Missing start_time for resource event {resource_event}"
227
+ )
228
+ events.extend(create_event(**resource_event))
229
+ return events
@@ -0,0 +1,19 @@
1
+ def create_event(worker_name, test_name, category, start_time, end_time, **kwargs):
2
+ start_event = {
3
+ "name": test_name.rsplit("/")[-1],
4
+ "cat": category,
5
+ "ph": "B",
6
+ "pid": worker_name,
7
+ "tid": 0,
8
+ "ts": start_time * (1000**2),
9
+ "args": kwargs,
10
+ }
11
+ end_event = {
12
+ "name": test_name.rsplit("/")[-1],
13
+ "cat": "pipeline",
14
+ "ph": "E",
15
+ "pid": worker_name,
16
+ "tid": 0,
17
+ "ts": end_time * (1000**2),
18
+ }
19
+ return start_event, end_event
@@ -0,0 +1,90 @@
1
+ import json
2
+ import os
3
+ import random
4
+ import tempfile
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from time import sleep
8
+ from unittest import mock
9
+
10
+ import filelock
11
+ import pytest
12
+
13
+ from pytest_isolate.plugin import allocate_resources, get_resource_events
14
+ from pytest_isolate.resource_management import (
15
+ log_resource_allocation,
16
+ parse_resource_list,
17
+ register_resource_provider,
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def temp_resource_files():
23
+ """Create temporary files for resource manager testing"""
24
+ with tempfile.TemporaryDirectory() as tmpdir:
25
+ lock_file = Path(tmpdir) / "resources.lock"
26
+ state_file = Path(tmpdir) / "resources.json"
27
+ yield lock_file, state_file
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_pynvml():
32
+ """Mock pynvml module for testing"""
33
+ pynvml_mock = mock.MagicMock()
34
+ pynvml_mock.nvmlDeviceGetCount.return_value = 4
35
+
36
+ # Mock the import so it returns our mock when imported in plugin.py
37
+ with mock.patch.dict("sys.modules", {"pynvml": pynvml_mock}):
38
+ yield pynvml_mock
39
+
40
+
41
+ def test_parse_resource_list():
42
+ """Test parsing resources from environment variables."""
43
+ # Test empty input
44
+ assert parse_resource_list(None) == []
45
+ assert parse_resource_list("") == []
46
+
47
+ # Test simple cases
48
+ assert parse_resource_list("0") == [0]
49
+ assert parse_resource_list("0,1,2") == [0, 1, 2]
50
+
51
+ # Test with spaces
52
+ assert parse_resource_list(" 0, 1, 2 ") == [0, 1, 2]
53
+
54
+ # Test with invalid values - no warning in new implementation
55
+ assert parse_resource_list("0,a,2") == []
56
+
57
+ def test_log_resource_allocation():
58
+ log_resource_allocation([0,1],'gpu','foo', start_time=100,file="timeline.events.test.json")
59
+
60
+ log_resource_allocation([0,1],'gpu','foo', end_time=200,file="timeline.events.test.json")
61
+ events = get_resource_events(file="timeline.events.test.json")
62
+ open("timeline.resources.test.json", "w").write(json.dumps(events))
63
+
64
+
65
+ def test_allocate_resources():
66
+ with allocate_resources({"gpu": 1}, "foo", 100) as allocated:
67
+ assert len(allocated["gpu"]) == 1
68
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES","").split(","))
69
+
70
+
71
+
72
+ @pytest.mark.isolate(resources={"gpu": 2}, timeout=10)
73
+ @pytest.mark.parametrize("dummy", [1, 2])
74
+ def test_gpu_marker_2(dummy):
75
+ sleep(0.3)
76
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 2
77
+
78
+
79
+ @pytest.mark.isolate(resources={"gpu": 1}, timeout=10)
80
+ @pytest.mark.parametrize("dummy", [1, 2])
81
+ def test_gpu_marker_1(dummy):
82
+ sleep(0.3)
83
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 1
84
+
85
+
86
+ @pytest.mark.xfail
87
+ @pytest.mark.isolate(resources={"gpu": 7}, timeout=4)
88
+ def test_gpu_marker_7():
89
+ sleep(0.3)
90
+ assert len(os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")) == 7
@@ -11,7 +11,9 @@ deps =
11
11
  numpy
12
12
  pytest-xdist
13
13
  pytest-sugar
14
-
14
+ pytest-mock
15
+ setenv =
16
+ CUDA_VISIBLE_DEVICES = 0,1
15
17
  commands = pytest --isolate --import-mode importlib {posargs:tests} --timeline
16
18
 
17
19
  [testenv:lint]
@@ -1 +0,0 @@
1
- __version__ = "0.0.4"