Skip to content

Commit 2c8cc5c

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-2962 Rule S7487: Async functions should not contain synchronous subprocess calls (#280)
GitOrigin-RevId: 00d3f288d80d7bb3d39d9a3e7e6ca79f12d5d3df
1 parent 6a5e726 commit 2c8cc5c

7 files changed

Lines changed: 337 additions & 0 deletions

File tree

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ public Stream<Class<?>> getChecks() {
364364
SuperfluousCurlyBraceCheck.class,
365365
SynchronousFileOperationsInAsyncCheck.class,
366366
SynchronousHttpOperationsInAsyncCheck.class,
367+
SynchronousSubprocessOperationsInAsyncCheck.class,
367368
SynchronousOsCallsInAsyncCheck.class,
368369
TempFileCreationCheck.class,
369370
ImplicitlySkippedTestCheck.class,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
18+
package org.sonar.python.checks;
19+
20+
import java.util.Set;
21+
import org.sonar.check.Rule;
22+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
23+
import org.sonar.plugins.python.api.SubscriptionContext;
24+
import org.sonar.plugins.python.api.tree.CallExpression;
25+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
26+
import org.sonar.plugins.python.api.tree.Tree;
27+
import org.sonar.python.tree.TreeUtils;
28+
import org.sonar.python.types.v2.TypeCheckMap;
29+
30+
@Rule(key = "S7487")
31+
public class SynchronousSubprocessOperationsInAsyncCheck extends PythonSubscriptionCheck {
32+
33+
private static final String MESSAGE = "Use an async subprocess call in this async function instead of a synchronous one.";
34+
private static final String SECONDARY_MESSAGE = "This function is async.";
35+
36+
private static final Set<String> SYNC_SUBPROCESS_CALLS = Set.of(
37+
"subprocess.run",
38+
"subprocess.Popen",
39+
"subprocess.call",
40+
"subprocess.check_call",
41+
"subprocess.check_output",
42+
"subprocess.getstatusoutput",
43+
"subprocess.getoutput",
44+
"os.system",
45+
"os.popen",
46+
"os.spawnl",
47+
"os.spawnle",
48+
"os.spawnlp",
49+
"os.spawnlpe",
50+
"os.spawnv",
51+
"os.spawnve",
52+
"os.spawnvp",
53+
"os.spawnvpe");
54+
55+
private TypeCheckMap<Object> allSyncTypeChecks;
56+
57+
@Override
58+
public void initialize(Context context) {
59+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initializeTypeCheckMap);
60+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCallExpression);
61+
}
62+
63+
private void initializeTypeCheckMap(SubscriptionContext ctx) {
64+
var marker = new Object();
65+
allSyncTypeChecks = new TypeCheckMap<>();
66+
SYNC_SUBPROCESS_CALLS.forEach(fqn -> allSyncTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName(fqn), marker));
67+
}
68+
69+
private void checkCallExpression(SubscriptionContext ctx) {
70+
var call = (CallExpression) ctx.syntaxNode();
71+
var asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(call).orElse(null);
72+
if (asyncToken == null) {
73+
return;
74+
}
75+
76+
if (allSyncTypeChecks.getOptionalForType(call.callee().typeV2()).isPresent()) {
77+
ctx.addIssue(call.callee(), MESSAGE).secondary(asyncToken, SECONDARY_MESSAGE);
78+
return;
79+
}
80+
81+
if (call.callee() instanceof QualifiedExpression member && allSyncTypeChecks.getOptionalForType(member.qualifier().typeV2()).isPresent()) {
82+
ctx.addIssue(call.callee(), MESSAGE).secondary(asyncToken, SECONDARY_MESSAGE);
83+
}
84+
}
85+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<p>This rule raises an issue when synchronous subprocess calls are used within asynchronous functions.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Using synchronous subprocess calls like <code>subprocess.Popen</code> or similar functions in asynchronous code blocks the entire event loop. This
4+
undermines the primary advantage of asynchronous programming - the ability to perform concurrent operations without blocking execution.</p>
5+
<p>When an async function makes a synchronous call to create a subprocess:</p>
6+
<ul>
7+
<li> The event loop is completely blocked until the subprocess operation completes </li>
8+
<li> No other coroutines can run during this time, even if they’re ready to execute </li>
9+
<li> The responsiveness of the application is degraded </li>
10+
<li> In server applications, this can cause timeouts or failures for other concurrent requests </li>
11+
</ul>
12+
<p>Instead, async libraries provide dedicated APIs for running subprocesses in a non-blocking way:</p>
13+
<ul>
14+
<li> <code>asyncio.create_subprocess_exec()</code> and <code>asyncio.create_subprocess_shell()</code> for asyncio </li>
15+
<li> <code>trio.run_process()</code> for Trio </li>
16+
<li> <code>anyio.run_process()</code> for AnyIO </li>
17+
</ul>
18+
<p>Using these APIs allows other tasks to continue executing while waiting for the subprocess to complete.</p>
19+
<h2>How to fix it in Asyncio</h2>
20+
<p>Replace synchronous subprocess calls with <code>asyncio.create_subprocess_exec()</code> or <code>asyncio.create_subprocess_shell()</code> depending
21+
on whether you need to run a specific command with arguments or a shell command string.</p>
22+
<h3>Code examples</h3>
23+
<h4>Noncompliant code example</h4>
24+
<pre data-diff-id="1" data-diff-type="noncompliant">
25+
import subprocess
26+
27+
async def process_data():
28+
subprocess.run(["wget", "https://example.com/file.zip"]) # Noncompliant
29+
</pre>
30+
<h4>Compliant solution</h4>
31+
<pre data-diff-id="1" data-diff-type="compliant">
32+
import asyncio
33+
34+
async def process_data():
35+
proc = await asyncio.create_subprocess_exec("wget", "https://example.com/file.zip")
36+
result = await proc.wait()
37+
</pre>
38+
<h2>How to fix it in Trio</h2>
39+
<p>Replace synchronous subprocess calls with <code>trio.run_process()</code>, which handles both command arrays and shell commands.</p>
40+
<h3>Code examples</h3>
41+
<h4>Noncompliant code example</h4>
42+
<pre data-diff-id="2" data-diff-type="noncompliant">
43+
import trio
44+
import subprocess
45+
46+
async def download_files():
47+
result = subprocess.run(["wget", "https://example.com/file.zip"]) # Noncompliant
48+
</pre>
49+
<h4>Compliant solution</h4>
50+
<pre data-diff-id="2" data-diff-type="compliant">
51+
import trio
52+
53+
async def download_files():
54+
result = await trio.run_process(["wget", "https://example.com/file.zip"])
55+
</pre>
56+
<h2>How to fix it in AnyIO</h2>
57+
<p>Replace synchronous subprocess calls with <code>anyio.run_process()</code>, which works similar to Trio’s API and supports both command arrays and
58+
shell commands.</p>
59+
<h3>Code examples</h3>
60+
<h4>Noncompliant code example</h4>
61+
<pre data-diff-id="3" data-diff-type="noncompliant">
62+
import subprocess
63+
64+
async def process_image():
65+
result = subprocess.run(["wget", "https://example.com/file.zip"]) # Noncompliant
66+
</pre>
67+
<h4>Compliant solution</h4>
68+
<pre data-diff-id="3" data-diff-type="compliant">
69+
import anyio
70+
71+
async def process_image():
72+
result = await anyio.run_process(["wget", "https://example.com/file.zip"])
73+
</pre>
74+
<h2>Resources</h2>
75+
<h3>Documentation</h3>
76+
<ul>
77+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-subprocess.html">Subprocess</a> </li>
78+
<li> Trio - <a href="https://trio.readthedocs.io/en/stable/reference-io.html#trio.run_process">run_process() documentation</a> </li>
79+
<li> AnyIO - <a href="https://anyio.readthedocs.io/en/stable/subprocesses.html">Subprocesses</a> </li>
80+
</ul>
81+
<h3>Articles &amp; blog posts</h3>
82+
<ul>
83+
<li> Python - <a href="https://realpython.com/python-concurrency/">Concurrency and Parallelism in Python</a> </li>
84+
</ul>
85+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Async functions should not contain synchronous subprocess calls",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"anyio",
13+
"trio"
14+
],
15+
"defaultSeverity": "Major",
16+
"ruleSpecification": "RSPEC-7487",
17+
"sqKey": "S7487",
18+
"scope": "All",
19+
"quickfix": "unknown",
20+
"code": {
21+
"impacts": {
22+
"RELIABILITY": "HIGH"
23+
},
24+
"attribute": "EFFICIENT"
25+
}
26+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
"S6985",
257257
"S7483",
258258
"S7486",
259+
"S7487",
259260
"S7488",
260261
"S7489",
261262
"S7491",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
18+
package org.sonar.python.checks;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.sonar.python.checks.utils.PythonCheckVerifier;
22+
23+
class SynchronousSubprocessOperationsInAsyncCheckTest {
24+
25+
@Test
26+
void test() {
27+
PythonCheckVerifier.verify("src/test/resources/checks/synchronousSubprocessOperationsInAsync.py", new SynchronousSubprocessOperationsInAsyncCheck());
28+
}
29+
30+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import asyncio
2+
import subprocess
3+
import trio
4+
import anyio
5+
import os
6+
7+
8+
# Asyncio cases
9+
async def asyncio_with_subprocess_run():
10+
# ^[sc=1;ec=5]> {{This function is async.}}
11+
result = subprocess.run(["echo", "Hello World"]) # Noncompliant {{Use an async subprocess call in this async function instead of a synchronous one.}}
12+
# ^^^^^^^^^^^^^^
13+
14+
async def asyncio_with_subprocess_popen():
15+
proc = subprocess.Popen(["ls", "-l"]) # Noncompliant
16+
proc.wait() # Noncompliant
17+
18+
async def asyncio_with_subprocess_call():
19+
subprocess.call(["date"]) # Noncompliant
20+
21+
async def asyncio_with_subprocess_check_call():
22+
subprocess.check_call(["whoami"]) # Noncompliant
23+
24+
async def asyncio_with_subprocess_check_output():
25+
output = subprocess.check_output(["hostname"]) # Noncompliant
26+
27+
async def asyncio_with_os_system():
28+
os.system("echo Hello") # Noncompliant
29+
30+
async def asyncio_with_popen_communicate():
31+
proc = subprocess.Popen(["echo", "test"]) # Noncompliant
32+
stdout, stderr = proc.communicate() # Noncompliant
33+
34+
async def asyncio_with_os_spawn():
35+
os.spawnl(os.P_WAIT, "/bin/echo", "echo", "hello") # Noncompliant
36+
37+
async def asyncio_with_subprocess_getstatusoutput():
38+
status, output = subprocess.getstatusoutput("ls") # Noncompliant
39+
40+
async def asyncio_with_subprocess_getoutput():
41+
output = subprocess.getoutput("whoami") # Noncompliant
42+
43+
async def asyncio_compliant():
44+
proc = await asyncio.create_subprocess_exec(
45+
"echo", "Hello World",
46+
stdout=asyncio.subprocess.PIPE
47+
)
48+
stdout, _ = await proc.communicate()
49+
50+
# Shell version
51+
shell_proc = await asyncio.create_subprocess_shell(
52+
"ls -l",
53+
stdout=asyncio.subprocess.PIPE
54+
)
55+
await shell_proc.wait()
56+
57+
58+
# Trio cases
59+
async def trio_with_subprocess_run():
60+
async with trio.open_nursery() as nursery:
61+
result = subprocess.run(["ping", "-c", "1", "localhost"]) # Noncompliant
62+
63+
async def trio_with_subprocess_popen():
64+
async with trio.open_nursery() as nursery:
65+
proc = subprocess.Popen(["python", "--version"]) # Noncompliant
66+
67+
async def trio_with_popen_communicate():
68+
async with trio.open_nursery() as nursery:
69+
proc = subprocess.Popen(["echo", "trio test"]) # Noncompliant
70+
stdout, stderr = proc.communicate() # Noncompliant
71+
72+
async def trio_compliant():
73+
async with trio.open_nursery() as nursery:
74+
stdout = await trio.run_process(["echo", "Hello Trio"])
75+
76+
77+
# AnyIO cases
78+
async def anyio_with_subprocess_run():
79+
async with anyio.create_task_group() as tg:
80+
result = subprocess.run(["find", ".", "-name", "*.py"]) # Noncompliant
81+
82+
async def anyio_with_subprocess_check_output():
83+
async with anyio.create_task_group() as tg:
84+
output = subprocess.check_output(["ls", "-la"]) # Noncompliant
85+
86+
async def anyio_with_popen_communicate():
87+
async with anyio.create_task_group() as tg:
88+
proc = subprocess.Popen(["cat", "/etc/hostname"]) # Noncompliant
89+
stdout, stderr = proc.communicate() # Noncompliant
90+
91+
async def anyio_with_os_spawn():
92+
async with anyio.create_task_group() as tg:
93+
os.spawnv(os.P_WAIT, "/bin/ls", ["ls", "-l"]) # Noncompliant
94+
95+
async def anyio_compliant():
96+
async with anyio.create_task_group() as tg:
97+
result = await anyio.run_process(["echo", "Hello AnyIO"])
98+
99+
# Test cases outside async functions (should not be flagged)
100+
def sync_function_with_subprocess():
101+
result = subprocess.run(["echo", "This is fine"])
102+
proc = subprocess.Popen(["ls"])
103+
proc.wait()
104+
proc.communicate()
105+
106+
def sync_function_with_os_calls():
107+
os.system("echo This is also fine")
108+
file_obj = os.popen("date")
109+
os.spawnl(os.P_WAIT, "/bin/echo", "echo", "fine")

0 commit comments

Comments
 (0)