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.
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/.github/workflows/python-package.yml +2 -2
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/PKG-INFO +54 -8
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/README.md +45 -2
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/pyproject.toml +6 -4
- pytest_isolate-0.0.8/src/pytest_isolate/__about__.py +1 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/src/pytest_isolate/plugin.py +261 -45
- pytest_isolate-0.0.8/src/pytest_isolate/resource_management.py +148 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/tests/test_isolate.py +8 -2
- pytest_isolate-0.0.8/tests/test_resource_management.py +81 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/tox.ini +7 -5
- pytest_isolate-0.0.3/src/pytest_isolate/__about__.py +0 -1
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/.github/workflows/python-publish.yml +0 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/.gitignore +0 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/LICENSE.txt +0 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/src/pytest_isolate/__init__.py +0 -0
- {pytest_isolate-0.0.3 → pytest_isolate-0.0.8}/tests/conftest.py +0 -0
|
@@ -1,25 +1,28 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-isolate
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 -
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
for
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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{
|
|
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
|
-
|
|
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.
|
|
25
|
-
3.
|
|
26
|
+
3.9 = py39
|
|
27
|
+
3.12 = py312
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.3'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|