Skip to content

Commit 2c7f5aa

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-2941 Rule S7501: Async functions should not contain input() calls (#257)
GitOrigin-RevId: a7599a3d09c37d4b74b614fc428bb5c019bc7d73
1 parent 8e551c3 commit 2c7f5aa

10 files changed

Lines changed: 567 additions & 1 deletion

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
package org.sonar.python.checks;
18+
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
23+
import org.sonar.check.Rule;
24+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
25+
import org.sonar.plugins.python.api.SubscriptionContext;
26+
import org.sonar.plugins.python.api.tree.AliasedName;
27+
import org.sonar.plugins.python.api.tree.CallExpression;
28+
import org.sonar.plugins.python.api.tree.Expression;
29+
import org.sonar.plugins.python.api.tree.ImportName;
30+
import org.sonar.plugins.python.api.tree.Name;
31+
import org.sonar.plugins.python.api.tree.Tree;
32+
import org.sonar.plugins.python.api.types.v2.TriBool;
33+
import org.sonar.python.tree.TreeUtils;
34+
import org.sonar.python.types.v2.TypeCheckBuilder;
35+
36+
@Rule(key = "S7501")
37+
public class InputInAsyncCheck extends PythonSubscriptionCheck {
38+
39+
private static final String MESSAGE_TO_THREAD = "Wrap this call to input() with await %s.to_thread(input).";
40+
private static final String MESSAGE_TO_THREAD_RUN_SYNC = "Wrap this call to input() with await %s.to_thread.run_sync(input).";
41+
private static final String MESSAGE_FALLBACK = "Wrap this call to input() with the appropriate function from the asynchronous library.";
42+
private static final String SECONDARY_MESSAGE = "This function is async.";
43+
44+
private static final String LIB_ASYNCIO = "asyncio";
45+
private static final String LIB_TRIO = "trio";
46+
private static final String LIB_ANYIO = "anyio";
47+
48+
private TypeCheckBuilder isInputCall;
49+
private final Map<String, String> asyncLibraryAliases = new HashMap<>();
50+
51+
@Override
52+
public void initialize(Context context) {
53+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx -> {
54+
isInputCall = ctx.typeChecker().typeCheckBuilder().isTypeWithName("input");
55+
asyncLibraryAliases.clear();
56+
});
57+
context.registerSyntaxNodeConsumer(Tree.Kind.IMPORT_NAME, this::checkImportName);
58+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkInputInAsync);
59+
}
60+
61+
private void checkImportName(SubscriptionContext ctx) {
62+
ImportName importName = (ImportName) ctx.syntaxNode();
63+
for (AliasedName module : importName.modules()) {
64+
List<Name> names = module.dottedName().names();
65+
if (names.size() > 1) {
66+
continue;
67+
}
68+
String moduleName = names.get(0).name();
69+
Name alias = module.alias();
70+
String moduleAlias = alias != null ? alias.name() : moduleName;
71+
if (LIB_ASYNCIO.equals(moduleName) || LIB_TRIO.equals(moduleName) || LIB_ANYIO.equals(moduleName)) {
72+
asyncLibraryAliases.put(moduleName, moduleAlias);
73+
}
74+
}
75+
}
76+
77+
private void checkInputInAsync(SubscriptionContext context) {
78+
CallExpression callExpression = (CallExpression) context.syntaxNode();
79+
Expression callee = callExpression.callee();
80+
if (isInputCall.check(callee.typeV2()) != TriBool.TRUE) {
81+
return;
82+
}
83+
TreeUtils.asyncTokenOfEnclosingFunction(callExpression)
84+
.ifPresent(asyncKeyword -> {
85+
String message = getMessage();
86+
context.addIssue(callee, message).secondary(asyncKeyword, SECONDARY_MESSAGE);
87+
});
88+
}
89+
90+
private String getMessage() {
91+
if (asyncLibraryAliases.size() != 1) {
92+
return MESSAGE_FALLBACK;
93+
}
94+
var library = asyncLibraryAliases.keySet().iterator().next();
95+
if (LIB_ASYNCIO.equals(library)) {
96+
return String.format(MESSAGE_TO_THREAD, asyncLibraryAliases.get(LIB_ASYNCIO));
97+
} else if (LIB_TRIO.equals(library)) {
98+
return String.format(MESSAGE_TO_THREAD_RUN_SYNC, asyncLibraryAliases.get(LIB_TRIO));
99+
} else {
100+
return String.format(MESSAGE_TO_THREAD_RUN_SYNC, asyncLibraryAliases.get(LIB_ANYIO));
101+
}
102+
}
103+
}

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
@@ -240,6 +240,7 @@ public Stream<Class<?>> getChecks() {
240240
InitReturnsValueCheck.class,
241241
InstanceAndClassMethodsAtLeastOnePositionalCheck.class,
242242
InstanceMethodSelfAsFirstCheck.class,
243+
InputInAsyncCheck.class,
243244
InvalidOpenModeCheck.class,
244245
InvalidRegexCheck.class,
245246
InvariantReturnCheck.class,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<p>This rule raises an issue when the built-in <code>input()</code> function is called in an asynchronous function.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>In Python’s asynchronous programming (using <code>asyncio</code>, <code>Trio</code>, or <code>AnyIO</code>), an event loop manages concurrent tasks
4+
by having them yield control during time-consuming operations, enabling other tasks to run.</p>
5+
<p>However, the synchronous <code>input()</code> function blocks the current thread until user input is received, and when called from a coroutine, it
6+
blocks the entire event loop, preventing other tasks from executing and making the application unresponsive - effectively defeating the purpose of
7+
asynchronous programming for applications requiring concurrent operations or user interaction.</p>
8+
<h2>How to fix it in Asyncio</h2>
9+
<p>You can use <code>asyncio.to_thread()</code> to run the <code>input()</code> function in a separate thread.</p>
10+
<h3>Code examples</h3>
11+
<h4>Noncompliant code example</h4>
12+
<pre data-diff-id="1" data-diff-type="noncompliant">
13+
import asyncio
14+
15+
async def get_name():
16+
print("Please enter your name:")
17+
name = input() # Noncompliant
18+
return name
19+
</pre>
20+
<h4>Compliant solution</h4>
21+
<pre data-diff-id="1" data-diff-type="compliant">
22+
import asyncio
23+
24+
async def get_name():
25+
print("Please enter your name:")
26+
name = await asyncio.to_thread(input) # Compliant
27+
return name
28+
</pre>
29+
<h2>How to fix it in Trio</h2>
30+
<p>You can use <code>trio.to_thread.run_sync()</code> to run the <code>input()</code> function in a separate thread.</p>
31+
<h3>Code examples</h3>
32+
<h4>Noncompliant code example</h4>
33+
<pre data-diff-id="2" data-diff-type="noncompliant">
34+
import trio
35+
36+
async def get_name():
37+
print("Please enter your name:")
38+
name = input() # Noncompliant
39+
return name
40+
</pre>
41+
<h4>Compliant solution</h4>
42+
<pre data-diff-id="2" data-diff-type="compliant">
43+
import trio
44+
45+
async def get_name():
46+
print("Please enter your name:")
47+
name = await trio.to_thread.run_sync(input) # Compliant
48+
return name
49+
</pre>
50+
<h2>How to fix it in AnyIO</h2>
51+
<p>You can use <code>anyio.to_thread.run_sync()</code> to run the <code>input()</code> function in a separate thread.</p>
52+
<h3>Code examples</h3>
53+
<h4>Noncompliant code example</h4>
54+
<pre data-diff-id="3" data-diff-type="noncompliant">
55+
import anyio
56+
57+
async def get_name():
58+
print("Please enter your name:")
59+
name = input() # Noncompliant
60+
return name
61+
</pre>
62+
<h4>Compliant solution</h4>
63+
<pre data-diff-id="3" data-diff-type="compliant">
64+
import anyio
65+
66+
async def get_name():
67+
print("Please enter your name:")
68+
name = await anyio.to_thread.run_sync(input) # Compliant
69+
return name
70+
</pre>
71+
<h2>Resources</h2>
72+
<h3>Documentation</h3>
73+
<ul>
74+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread">Running in threads</a> </li>
75+
<li> Trio - <a href="https://trio.readthedocs.io/en/stable/reference-core.html#trio.to_thread.run_sync">Putting blocking I/O into worker threads</a>
76+
</li>
77+
<li> AnyIO - <a href="https://anyio.readthedocs.io/en/stable/threads.html#calling-synchronous-code-from-a-worker-thread">Calling synchronous code
78+
from a worker thread</a> </li>
79+
</ul>
80+
<h3>Articles &amp; blog posts</h3>
81+
<ul>
82+
<li> Python - <a href="https://realpython.com/python-concurrency/">Concurrency and Parallelism in Python</a> </li>
83+
</ul>
84+
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 input() calls",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"trio",
13+
"anyio"
14+
],
15+
"defaultSeverity": "Major",
16+
"ruleSpecification": "RSPEC-7501",
17+
"sqKey": "S7501",
18+
"scope": "All",
19+
"quickfix": "unknown",
20+
"code": {
21+
"impacts": {
22+
"RELIABILITY": "HIGH"
23+
},
24+
"attribute": "LOGICAL"
25+
}
26+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@
257257
"S7488",
258258
"S7491",
259259
"S7483",
260-
"S7488"
260+
"S7488",
261+
"S7501"
261262
]
262263
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class InputInAsyncCheckTest {
23+
24+
@Test
25+
void testAsyncIO() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/inputInAsyncAsyncIO.py", new InputInAsyncCheck());
27+
}
28+
29+
@Test
30+
void testTrio() {
31+
PythonCheckVerifier.verify("src/test/resources/checks/inputInAsyncTrio.py", new InputInAsyncCheck());
32+
}
33+
34+
@Test
35+
void testAnyIO() {
36+
PythonCheckVerifier.verify("src/test/resources/checks/inputInAsyncAnyIO.py", new InputInAsyncCheck());
37+
}
38+
39+
@Test
40+
void test() {
41+
PythonCheckVerifier.verify("src/test/resources/checks/inputInAsync.py", new InputInAsyncCheck());
42+
}
43+
44+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import asyncio, trio, anyio as aio # noqa: E401
2+
import asyncio.tasks
3+
import something_else
4+
5+
6+
async def foo():
7+
input() # Noncompliant {{Wrap this call to input() with the appropriate function from the asynchronous library.}}
8+
9+
10+
def prevent_optimization():
11+
asyncio.run(foo)
12+
trio.run(foo())
13+
aio.run(foo())
14+
something_else.run(foo())

0 commit comments

Comments
 (0)