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.
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/PKG-INFO +40 -3
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/README.md +36 -2
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/pyproject.toml +4 -1
- pytest_isolate-0.0.9/src/pytest_isolate/__about__.py +1 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/src/pytest_isolate/plugin.py +169 -37
- pytest_isolate-0.0.9/src/pytest_isolate/resource_management.py +229 -0
- pytest_isolate-0.0.9/src/pytest_isolate/tracing.py +19 -0
- pytest_isolate-0.0.9/tests/test_resource_management.py +90 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/tox.ini +3 -1
- pytest_isolate-0.0.4/src/pytest_isolate/__about__.py +0 -1
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/.github/workflows/python-package.yml +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/.github/workflows/python-publish.yml +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/.gitignore +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/LICENSE.txt +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/src/pytest_isolate/__init__.py +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/tests/conftest.py +0 -0
- {pytest_isolate-0.0.4 → pytest_isolate-0.0.9}/tests/test_isolate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-isolate
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 -
|
|
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,
|
|
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
|
-
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|