Skip to content

Commit 050acfe

Browse files
github-actions[bot]DominicLaveryPierre-Sassoulas
authored
Calculate linter.config.jobs in cgroupsv2 environments (#10089) (#10169)
Co-authored-by: Pierre Sassoulas <[email protected]> (cherry picked from commit 6456374) Co-authored-by: Dominic Lavery <[email protected]> Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent d6992b8 commit 050acfe

File tree

4 files changed

+171
-17
lines changed

4 files changed

+171
-17
lines changed

custom_dict.txt

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ contextlib
6868
contextmanager
6969
contravariance
7070
contravariant
71+
cgroup
7172
CPython
7273
cpython
7374
csv

doc/whatsnew/fragments/10103.bugfix

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixes a crash that occurred when pylint was run in a container on a host with cgroupsv2 and restrictions on CPU usage.
2+
3+
Closes #10103

pylint/lint/run.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,30 @@ def _query_cpu() -> int | None:
4343
This is based on discussion and copied from suggestions in
4444
https://bugs.python.org/issue36054.
4545
"""
46-
cpu_quota, avail_cpu = None, None
46+
if Path("/sys/fs/cgroup/cpu.max").is_file():
47+
avail_cpu = _query_cpu_cgroupv2()
48+
else:
49+
avail_cpu = _query_cpu_cgroupsv1()
50+
return _query_cpu_handle_k8s_pods(avail_cpu)
51+
52+
53+
def _query_cpu_cgroupv2() -> int | None:
54+
avail_cpu = None
55+
with open("/sys/fs/cgroup/cpu.max", encoding="utf-8") as file:
56+
line = file.read().rstrip()
57+
fields = line.split()
58+
if len(fields) == 2:
59+
str_cpu_quota = fields[0]
60+
cpu_period = int(fields[1])
61+
# Make sure this is not in an unconstrained cgroup
62+
if str_cpu_quota != "max":
63+
cpu_quota = int(str_cpu_quota)
64+
avail_cpu = int(cpu_quota / cpu_period)
65+
return avail_cpu
66+
4767

68+
def _query_cpu_cgroupsv1() -> int | None:
69+
cpu_quota, avail_cpu = None, None
4870
if Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").is_file():
4971
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", encoding="utf-8") as file:
5072
# Not useful for AWS Batch based jobs as result is -1, but works on local linux systems
@@ -65,7 +87,10 @@ def _query_cpu() -> int | None:
6587
cpu_shares = int(file.read().rstrip())
6688
# For AWS, gives correct value * 1024.
6789
avail_cpu = int(cpu_shares / 1024)
90+
return avail_cpu
91+
6892

93+
def _query_cpu_handle_k8s_pods(avail_cpu: int | None) -> int | None:
6994
# In K8s Pods also a fraction of a single core could be available
7095
# As multiprocessing is not able to run only a "fraction" of process
7196
# assume we have 1 CPU available

tests/test_pylint_runners.py

+141-16
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import pytest
1919

2020
from pylint import run_pylint, run_pyreverse, run_symilar
21+
from pylint.lint.run import _query_cpu
2122
from pylint.testutils import GenericTestReporter as Reporter
2223
from pylint.testutils._run import _Run as Run
2324
from pylint.testutils.utils import _test_cwd
@@ -70,33 +71,157 @@ def test_pylint_argument_deduplication(
7071
assert err.value.code == 0
7172

7273

73-
def test_pylint_run_jobs_equal_zero_dont_crash_with_cpu_fraction(
74+
@pytest.mark.parametrize(
75+
"quota,shares,period",
76+
[
77+
# Shares path
78+
("-1", "2", ""),
79+
("-1", "1023", ""),
80+
("-1", "1024", ""),
81+
# Periods path
82+
("100", "", "200"),
83+
("999", "", "1000"),
84+
("1000", "", "1000"),
85+
],
86+
)
87+
def test_pylint_run_dont_crash_with_cgroupv1(
7488
tmp_path: pathlib.Path,
89+
quota: str,
90+
shares: str,
91+
period: str,
7592
) -> None:
7693
"""Check that the pylint runner does not crash if `pylint.lint.run._query_cpu`
7794
determines only a fraction of a CPU core to be available.
7895
"""
79-
builtin_open = open
96+
filepath = os.path.abspath(__file__)
97+
testargs = [filepath, "--jobs=0"]
8098

81-
def _mock_open(*args: Any, **kwargs: Any) -> BufferedReader:
82-
if args[0] == "/sys/fs/cgroup/cpu/cpu.cfs_quota_us":
83-
return mock_open(read_data=b"-1")(*args, **kwargs) # type: ignore[no-any-return]
84-
if args[0] == "/sys/fs/cgroup/cpu/cpu.shares":
85-
return mock_open(read_data=b"2")(*args, **kwargs) # type: ignore[no-any-return]
86-
return builtin_open(*args, **kwargs) # type: ignore[no-any-return]
99+
with _test_cwd(tmp_path):
100+
with pytest.raises(SystemExit) as err:
101+
with patch(
102+
"builtins.open",
103+
mock_cgroup_fs(quota=quota, shares=shares, period=period),
104+
):
105+
with patch("pylint.lint.run.Path", mock_cgroup_path(v2=False)):
106+
Run(testargs, reporter=Reporter())
107+
assert err.value.code == 0
87108

88-
pathlib_path = pathlib.Path
89-
90-
def _mock_path(*args: str, **kwargs: Any) -> pathlib.Path:
91-
if args[0] == "/sys/fs/cgroup/cpu/cpu.shares":
92-
return MagicMock(is_file=lambda: True)
93-
return pathlib_path(*args, **kwargs)
94109

110+
@pytest.mark.parametrize(
111+
"contents",
112+
[
113+
"1 2",
114+
"max 100000",
115+
],
116+
)
117+
def test_pylint_run_dont_crash_with_cgroupv2(
118+
tmp_path: pathlib.Path,
119+
contents: str,
120+
) -> None:
121+
"""Check that the pylint runner does not crash if `pylint.lint.run._query_cpu`
122+
determines only a fraction of a CPU core to be available.
123+
"""
95124
filepath = os.path.abspath(__file__)
96125
testargs = [filepath, "--jobs=0"]
126+
97127
with _test_cwd(tmp_path):
98128
with pytest.raises(SystemExit) as err:
99-
with patch("builtins.open", _mock_open):
100-
with patch("pylint.lint.run.Path", _mock_path):
129+
with patch("builtins.open", mock_cgroup_fs(max_v2=contents)):
130+
with patch("pylint.lint.run.Path", mock_cgroup_path(v2=True)):
101131
Run(testargs, reporter=Reporter())
102132
assert err.value.code == 0
133+
134+
135+
@pytest.mark.parametrize(
136+
"contents,expected",
137+
[
138+
("50000 100000", 1),
139+
("100000 100000", 1),
140+
("200000 100000", 2),
141+
("299999 100000", 2),
142+
("300000 100000", 3),
143+
# Unconstrained cgroup
144+
("max 100000", None),
145+
],
146+
)
147+
def test_query_cpu_cgroupv2(
148+
tmp_path: pathlib.Path,
149+
contents: str,
150+
expected: int,
151+
) -> None:
152+
"""Check that `pylint.lint.run._query_cpu` generates realistic values in cgroupsv2
153+
systems.
154+
"""
155+
with _test_cwd(tmp_path):
156+
with patch("builtins.open", mock_cgroup_fs(max_v2=contents)):
157+
with patch("pylint.lint.run.Path", mock_cgroup_path(v2=True)):
158+
cpus = _query_cpu()
159+
assert cpus == expected
160+
161+
162+
@pytest.mark.parametrize(
163+
"quota,shares,period,expected",
164+
[
165+
# Shares path
166+
("-1", "2", "", 1),
167+
("-1", "1023", "", 1),
168+
("-1", "1024", "", 1),
169+
("-1", "2048", "", 2),
170+
# Periods path
171+
("100", "", "200", 1),
172+
("999", "", "1000", 1),
173+
("1000", "", "1000", 1),
174+
("2000", "", "1000", 2),
175+
],
176+
)
177+
def test_query_cpu_cgroupv1(
178+
tmp_path: pathlib.Path,
179+
quota: str,
180+
shares: str,
181+
period: str,
182+
expected: int,
183+
) -> None:
184+
"""Check that `pylint.lint.run._query_cpu` generates realistic values in cgroupsv1
185+
systems.
186+
"""
187+
with _test_cwd(tmp_path):
188+
with patch(
189+
"builtins.open", mock_cgroup_fs(quota=quota, shares=shares, period=period)
190+
):
191+
with patch("pylint.lint.run.Path", mock_cgroup_path(v2=False)):
192+
cpus = _query_cpu()
193+
assert cpus == expected
194+
195+
196+
def mock_cgroup_path(v2: bool) -> Any:
197+
def _mock_path(*args: str, **kwargs: Any) -> pathlib.Path:
198+
if args[0] == "/sys/fs/cgroup/cpu/cpu.cfs_period_us":
199+
return MagicMock(is_file=lambda: not v2)
200+
if args[0] == "/sys/fs/cgroup/cpu/cpu.shares":
201+
return MagicMock(is_file=lambda: not v2)
202+
if args[0] == "/sys/fs/cgroup/cpu/cpu.cfs_quota_us":
203+
return MagicMock(is_file=lambda: not v2)
204+
if args[0] == "/sys/fs/cgroup/cpu.max":
205+
return MagicMock(is_file=lambda: v2)
206+
return pathlib.Path(*args, **kwargs)
207+
208+
return _mock_path
209+
210+
211+
def mock_cgroup_fs(
212+
quota: str = "", shares: str = "", period: str = "", max_v2: str = ""
213+
) -> Any:
214+
builtin_open = open
215+
216+
def _mock_open(*args: Any, **kwargs: Any) -> BufferedReader:
217+
if args[0] == "/sys/fs/cgroup/cpu/cpu.cfs_quota_us":
218+
return mock_open(read_data=quota)(*args, **kwargs) # type: ignore[no-any-return]
219+
if args[0] == "/sys/fs/cgroup/cpu/cpu.shares":
220+
return mock_open(read_data=shares)(*args, **kwargs) # type: ignore[no-any-return]
221+
if args[0] == "/sys/fs/cgroup/cpu/cpu.cfs_period_us":
222+
return mock_open(read_data=period)(*args, **kwargs) # type: ignore[no-any-return]
223+
if args[0] == "/sys/fs/cgroup/cpu.max":
224+
return mock_open(read_data=max_v2)(*args, **kwargs) # type: ignore[no-any-return]
225+
return builtin_open(*args, **kwargs) # type: ignore[no-any-return]
226+
227+
return _mock_open

0 commit comments

Comments
 (0)