Skip to content

Commit a5c8cbd

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-2952 Rule S7493: Async functions should not contain synchronous file operations (#260)
GitOrigin-RevId: 75f49bf6cec0c954d13eebf32f484c69a5b23e6d
1 parent b3cec28 commit a5c8cbd

7 files changed

Lines changed: 436 additions & 3 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
@@ -356,6 +356,7 @@ public Stream<Class<?>> getChecks() {
356356
SklearnPipelineSpecifyMemoryArgumentCheck.class,
357357
SklearnPipelineParameterAreCorrectCheck.class,
358358
SuperfluousCurlyBraceCheck.class,
359+
SynchronousFileOperationsInAsyncCheck.class,
359360
TempFileCreationCheck.class,
360361
SynchronousHttpOperationsInAsyncCheck.class,
361362
ImplicitlySkippedTestCheck.class,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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.List;
20+
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.Tree;
26+
import org.sonar.python.tree.TreeUtils;
27+
import org.sonar.python.types.v2.TypeCheckMap;
28+
29+
@Rule(key = "S7493")
30+
public class SynchronousFileOperationsInAsyncCheck extends PythonSubscriptionCheck {
31+
32+
private static final String MESSAGE = "Use an asynchronous file API instead of synchronous %s() in this async function.";
33+
private static final String SECONDARY_MESSAGE = "This function is async.";
34+
35+
private final TypeCheckMap<String> syncFileFunctions = new TypeCheckMap<>();
36+
37+
@Override
38+
public void initialize(Context context) {
39+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initializeTypeCheckMap);
40+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCallExpression);
41+
}
42+
43+
private static final List<String> SYNC_FILE_FUNCTIONS = List.of(
44+
"open",
45+
"os.open",
46+
"pathlib.Path.open",
47+
"codecs.open",
48+
"os.fdopen",
49+
"os.popen",
50+
"tempfile.TemporaryFile",
51+
"tempfile.NamedTemporaryFile",
52+
"tempfile.SpooledTemporaryFile",
53+
"gzip.open",
54+
"bz2.open",
55+
"lzma.open");
56+
57+
private void initializeTypeCheckMap(SubscriptionContext context) {
58+
SYNC_FILE_FUNCTIONS.forEach(fqn -> syncFileFunctions.put(context.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName(fqn), fqn));
59+
}
60+
61+
private void checkCallExpression(SubscriptionContext ctx) {
62+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
63+
var asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(callExpression).orElse(null);
64+
if (asyncToken == null) {
65+
return;
66+
}
67+
syncFileFunctions.getOptionalForType(callExpression.callee().typeV2()).ifPresent(
68+
functionName ->
69+
ctx.addIssue(callExpression, String.format(MESSAGE, functionName))
70+
.secondary(asyncToken, SECONDARY_MESSAGE));
71+
}
72+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<p>This rule raises an issue when synchronous file operations like <code>open()</code> are used within asynchronous functions.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Using synchronous file operations like <code>open()</code> in asynchronous code blocks the entire event loop. This undermines the primary advantage
4+
of asynchronous programming - the ability to perform concurrent operations without blocking execution.</p>
5+
<p>When an async function makes a synchronous file operation:</p>
6+
<ul>
7+
<li> The event loop is completely blocked until the file I/O 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 mechanisms to handle file operations asynchronously:</p>
13+
<ul>
14+
<li> <code>aiofiles</code> library for asyncio </li>
15+
<li> <code>trio.open_file()</code> for Trio </li>
16+
<li> <code>anyio.open_file()</code> for AnyIO </li>
17+
</ul>
18+
<p>Using these constructs allows other tasks to continue executing while waiting for the potentially blocking file operation to complete.</p>
19+
<h2>How to fix it in Asyncio</h2>
20+
<p>The standard library’s <code>asyncio</code> package doesn’t provide built-in asynchronous file I/O operations. Use the <code>aiofiles</code>
21+
library to handle file operations asynchronously.</p>
22+
<h3>Code examples</h3>
23+
<h4>Noncompliant code example</h4>
24+
<pre data-diff-id="1" data-diff-type="noncompliant">
25+
async def read_config():
26+
with open("config.json", "r") as file: # Noncompliant
27+
data = file.read()
28+
return data
29+
</pre>
30+
<h4>Compliant solution</h4>
31+
<pre data-diff-id="1" data-diff-type="compliant">
32+
import aiofiles
33+
34+
async def read_config():
35+
async with aiofiles.open("config.json", "r") as file: # Compliant
36+
data = await file.read()
37+
return data
38+
</pre>
39+
<h2>How to fix it in Trio</h2>
40+
<p>Use <code>trio.open_file()</code> to handle file operations asynchronously.</p>
41+
<h3>Code examples</h3>
42+
<h4>Noncompliant code example</h4>
43+
<pre data-diff-id="2" data-diff-type="noncompliant">
44+
async def read_config():
45+
with open("config.json", "r") as file: # Noncompliant
46+
data = file.read()
47+
return data
48+
</pre>
49+
<h4>Compliant solution</h4>
50+
<pre data-diff-id="2" data-diff-type="compliant">
51+
import trio
52+
53+
async def read_config():
54+
async with await trio.open_file("config.json", "r") as file: # Compliant
55+
data = await file.read()
56+
return data
57+
</pre>
58+
<h2>How to fix it in AnyIO</h2>
59+
<p>Use <code>anyio.open_file()</code> to handle file operations asynchronously.</p>
60+
<h3>Code examples</h3>
61+
<h4>Noncompliant code example</h4>
62+
<pre data-diff-id="3" data-diff-type="noncompliant">
63+
async def read_config():
64+
with open("config.json", "r") as file: # Noncompliant
65+
data = file.read()
66+
return data
67+
</pre>
68+
<h4>Compliant solution</h4>
69+
<pre data-diff-id="3" data-diff-type="compliant">
70+
import anyio
71+
72+
async def read_config():
73+
async with await anyio.open_file("config.json", "r") as file: # Compliant
74+
data = await file.read()
75+
return data
76+
</pre>
77+
<h2>Resources</h2>
78+
<h3>Documentation</h3>
79+
<ul>
80+
<li> Trio - <a href="https://trio.readthedocs.io/en/stable/reference-io.html#trio.open_file">trio.open_file() documentation</a> </li>
81+
<li> AnyIO - <a href="https://anyio.readthedocs.io/en/stable/fileio.html">File I/O in AnyIO</a> </li>
82+
<li> Aiofiles - <a href="https://github.com/Tinche/aiofiles">aiofiles project</a> </li>
83+
</ul>
84+
<h3>Articles &amp; blog posts</h3>
85+
<ul>
86+
<li> Python - <a href="https://realpython.com/async-io-python/">Async IO in Python</a> </li>
87+
</ul>
88+
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 file operations",
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-7493",
17+
"sqKey": "S7493",
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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,8 @@
256256
"S6985",
257257
"S7483",
258258
"S7488",
259-
"S7488",
260259
"S7491",
261-
"S7483",
262-
"S7488",
260+
"S7493",
263261
"S7499",
264262
"S7501"
265263
]
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 SynchronousFileOperationsInAsyncCheckTest {
24+
25+
@Test
26+
void test() {
27+
PythonCheckVerifier.verify("src/test/resources/checks/synchronousFileOperationsInAsync.py", new SynchronousFileOperationsInAsyncCheck());
28+
}
29+
30+
}

0 commit comments

Comments
 (0)