Skip to content

Commit 2140539

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-3002 Rule S7515: async with should be used for asynchronous resource management (#318)
GitOrigin-RevId: a9fb1fed2a8d5a4bcddf47850fc56553f3616dca
1 parent c148469 commit 2140539

File tree

7 files changed

+296
-0
lines changed

7 files changed

+296
-0
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.tree.Expression;
23+
import org.sonar.plugins.python.api.tree.Tree;
24+
import org.sonar.plugins.python.api.tree.WithItem;
25+
import org.sonar.plugins.python.api.tree.WithStatement;
26+
import org.sonar.plugins.python.api.types.v2.PythonType;
27+
import org.sonar.plugins.python.api.types.v2.TriBool;
28+
import org.sonar.python.tree.TreeUtils;
29+
import org.sonar.python.types.v2.TypeCheckBuilder;
30+
31+
@Rule(key = "S7515")
32+
public class AsyncWithContextManagerCheck extends PythonSubscriptionCheck {
33+
34+
private static final String MESSAGE = "Use \"async with\" instead of \"with\" for this asynchronous context manager.";
35+
private static final String SECONDARY_TYPE_DEFINITION = "This context manager implements the async context manager protocol.";
36+
private static final String SECONDARY_MESSAGE = "This function is async.";
37+
38+
private TypeCheckBuilder hasAsyncEnter;
39+
private TypeCheckBuilder hasAsyncExit;
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecker);
44+
context.registerSyntaxNodeConsumer(Tree.Kind.WITH_STMT, this::checkWithStatement);
45+
}
46+
47+
private void setupTypeChecker(SubscriptionContext ctx) {
48+
hasAsyncEnter = ctx.typeChecker().typeCheckBuilder().hasMember("__aenter__");
49+
hasAsyncExit = ctx.typeChecker().typeCheckBuilder().hasMember("__aexit__");
50+
}
51+
52+
private void checkWithStatement(SubscriptionContext ctx) {
53+
WithStatement withStatement = (WithStatement) ctx.syntaxNode();
54+
if (withStatement.isAsync()) {
55+
return;
56+
}
57+
var asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(withStatement).orElse(null);
58+
if (asyncToken == null) {
59+
return;
60+
}
61+
for (WithItem item : withStatement.withItems()) {
62+
Expression contextManager = item.test();
63+
PythonType contextManagerType = contextManager.typeV2();
64+
if (implementsAsyncContextManagerProtocol(contextManagerType)) {
65+
PreciseIssue issue = ctx.addIssue(withStatement.withKeyword(), MESSAGE)
66+
.secondary(asyncToken, SECONDARY_MESSAGE);
67+
contextManagerType.definitionLocation().ifPresent(
68+
location -> issue.secondary(location, SECONDARY_TYPE_DEFINITION)
69+
);
70+
break;
71+
}
72+
}
73+
}
74+
75+
private boolean implementsAsyncContextManagerProtocol(PythonType type) {
76+
return hasAsyncEnter.check(type) == TriBool.TRUE && hasAsyncExit.check(type) == TriBool.TRUE;
77+
}
78+
}

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
@@ -125,6 +125,7 @@ public Stream<Class<?>> getChecks() {
125125
AsyncFunctionWithTimeoutCheck.class,
126126
AsyncioTaskNotStoredCheck.class,
127127
AsyncLongSleepCheck.class,
128+
AsyncWithContextManagerCheck.class,
128129
BackslashInStringCheck.class,
129130
BackticksUsageCheck.class,
130131
BareRaiseInFinallyCheck.class,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<p>This rule raises when using a regular <code>with</code> statement inside an async function with a context manager that implements the asynchronous
2+
context manager protocol.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>When working within an async function, it is important to maintain consistency with the asynchronous programming model. If a context manager
5+
implements the asynchronous context manager protocol (defining <code>__aenter__</code> and <code>__aexit__</code> methods), it should be used with the
6+
<code>async with</code> statement rather than the regular <code>with</code> statement.</p>
7+
<p>The asynchronous context manager protocol is specifically designed to handle resources that may require asynchronous setup or teardown operations.
8+
Using the regular <code>with</code> statement in an async context bypasses this intended asynchronous behavior.</p>
9+
<h3>What is the potential impact?</h3>
10+
<p>Not following the proper async pattern can lead to:</p>
11+
<ul>
12+
<li> <strong>Inconsistent async usage</strong>: Mixing synchronous and asynchronous patterns reduces code clarity </li>
13+
<li> <strong>Missed async opportunities</strong>: Asynchronous setup and cleanup operations may not be utilized </li>
14+
<li> <strong>Maintenance issues</strong>: Future developers may not understand the intended async behavior </li>
15+
</ul>
16+
<h2>How to fix it</h2>
17+
<p>Use the <code>async with</code> statement when working with asynchronous context managers.</p>
18+
<h3>Code examples</h3>
19+
<h4>Noncompliant code example</h4>
20+
<pre data-diff-id="1" data-diff-type="noncompliant">
21+
class Resource:
22+
def __enter__(self):
23+
return self
24+
25+
def __exit__(self, exc_type, exc, tb):
26+
...
27+
28+
async def __aenter__(self):
29+
return self
30+
31+
async def __aexit__(self, exc_type, exc, tb):
32+
...
33+
34+
async def main():
35+
resource = Resource()
36+
with resource: # Noncompliant: using 'with' in async function when async protocol is available
37+
...
38+
</pre>
39+
<h4>Compliant solution</h4>
40+
<pre data-diff-id="1" data-diff-type="compliant">
41+
class Resource:
42+
def __enter__(self):
43+
return self
44+
45+
def __exit__(self, exc_type, exc, tb):
46+
...
47+
48+
async def __aenter__(self):
49+
return self
50+
51+
async def __aexit__(self, exc_type, exc, tb):
52+
...
53+
54+
async def main():
55+
async with Resource() as resource: # Compliant: using 'async with' in async function
56+
...
57+
</pre>
58+
<h3>How does this work?</h3>
59+
<p>The <code>async with</code> statement provides the proper way to use asynchronous context managers:</p>
60+
<ol>
61+
<li> It calls the <code>__aenter__</code> method and awaits its result </li>
62+
<li> Assigns the returned value to the variable after <code>as</code> (if specified) </li>
63+
<li> Executes the code block within the context </li>
64+
<li> Calls the <code>__aexit__</code> method and awaits its completion, even if an exception occurs </li>
65+
</ol>
66+
<p>This ensures consistency with the async programming model and allows the context manager to perform any necessary asynchronous operations during
67+
setup and cleanup.</p>
68+
<h2>Resources</h2>
69+
<h3>Documentation</h3>
70+
<ul>
71+
<li> Python Documentation - <a href="https://docs.python.org/3/reference/datamodel.html#asynchronous-context-managers">Asynchronous Context
72+
Managers</a> </li>
73+
<li> Python Documentation - <a href="https://docs.python.org/3/reference/compound_stmts.html#the-async-with-statement">The async with statement</a>
74+
</li>
75+
</ul>
76+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "\"async with\" should be used for asynchronous resource management",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async"
11+
],
12+
"defaultSeverity": "Major",
13+
"ruleSpecification": "RSPEC-7515",
14+
"sqKey": "S7515",
15+
"scope": "All",
16+
"quickfix": "targeted",
17+
"code": {
18+
"impacts": {
19+
"MAINTAINABILITY": "LOW",
20+
"RELIABILITY": "LOW"
21+
},
22+
"attribute": "LOGICAL"
23+
}
24+
}

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
@@ -283,6 +283,7 @@
283283
"S7512",
284284
"S7513",
285285
"S7514",
286+
"S7515",
286287
"S7516",
287288
"S7517",
288289
"S7519"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 AsyncWithContextManagerCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/asyncWithContextManager.py", new AsyncWithContextManagerCheck());
27+
}
28+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Test cases for async context managers
2+
3+
class AsyncContextManager:
4+
# ^^^^^^^^^^^^^^^^^^^> {{This context manager implements the async context manager protocol.}}
5+
async def __aenter__(self):
6+
print("Entering async context")
7+
return self
8+
9+
async def __aexit__(self, exc_type, exc_val, exc_tb):
10+
print("Exiting async context")
11+
return False
12+
13+
class RegularContextManager:
14+
def __enter__(self):
15+
print("Entering regular context")
16+
return self
17+
18+
def __exit__(self, exc_type, exc_val, exc_tb):
19+
print("Exiting regular context")
20+
return False
21+
22+
class DualContextManager:
23+
# Implements both sync and async protocols
24+
def __enter__(self):
25+
print("Entering regular context")
26+
return self
27+
28+
def __exit__(self, exc_type, exc_val, exc_tb):
29+
print("Exiting regular context")
30+
return False
31+
32+
async def __aenter__(self):
33+
print("Entering async context")
34+
return self
35+
36+
async def __aexit__(self, exc_type, exc_val, exc_tb):
37+
print("Exiting async context")
38+
return False
39+
40+
async def async_function_with_async_context_manager():
41+
#^[sc=1;ec=5]> {{This function is async.}}
42+
with AsyncContextManager() as acm: # Noncompliant {{Use "async with" instead of "with" for this asynchronous context manager.}}
43+
# ^^^^
44+
print("Inside async context")
45+
46+
async def async_function_with_dual_context_manager():
47+
with DualContextManager() as dcm: # Noncompliant
48+
print("Inside context")
49+
50+
# Compliant examples
51+
async def async_function_with_async_with():
52+
async with AsyncContextManager() as acm: # Compliant
53+
print("Inside async context")
54+
55+
async def async_function_with_regular_context_manager():
56+
with RegularContextManager() as rcm: # Compliant - no async protocol
57+
print("Inside regular context")
58+
59+
async def async_function_with_dual_context_manager_compliant():
60+
async with DualContextManager() as dcm: # Compliant
61+
print("Inside async context")
62+
63+
# Regular function - should be compliant regardless
64+
def regular_function():
65+
with AsyncContextManager() as acm: # Compliant - not in async function
66+
print("Inside async context")
67+
68+
with DualContextManager() as dcm: # Compliant - not in async function
69+
print("Inside context")
70+
71+
# Nested contexts
72+
async def nested_contexts():
73+
with RegularContextManager() as outer: # Compliant - no async protocol
74+
with AsyncContextManager() as inner: # Noncompliant
75+
print("Nested contexts")
76+
77+
# Class method examples
78+
class MyClass:
79+
async def async_method(self):
80+
with AsyncContextManager() as acm: # Noncompliant
81+
print("Inside async method")
82+
83+
async with AsyncContextManager() as acm: # Compliant
84+
print("Inside async method with async with")
85+
86+
def regular_method(self):
87+
with AsyncContextManager() as acm: # Compliant - not in async method
88+
print("Inside regular method")

0 commit comments

Comments
 (0)