Skip to content

Commit 06dd76d

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-2928 Rule S7486: Long sleep durations should use sleep_forever() instead of arbitrary intervals (#266)
GitOrigin-RevId: 5057e6663abe830b66c22a7ecdc76d707e624c77
1 parent 51ed079 commit 06dd76d

10 files changed

Lines changed: 281 additions & 5 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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.Map;
21+
import java.util.Optional;
22+
import org.sonar.check.Rule;
23+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
24+
import org.sonar.plugins.python.api.SubscriptionContext;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.Expression;
27+
import org.sonar.plugins.python.api.tree.RegularArgument;
28+
import org.sonar.plugins.python.api.tree.Token;
29+
import org.sonar.plugins.python.api.tree.Tree;
30+
import org.sonar.plugins.python.api.types.v2.TriBool;
31+
import org.sonar.python.checks.hotspots.CommonValidationUtils;
32+
import org.sonar.python.tree.TreeUtils;
33+
import org.sonar.python.types.v2.TypeCheckBuilder;
34+
35+
@Rule(key = "S7486")
36+
public class AsyncLongSleepCheck extends PythonSubscriptionCheck {
37+
38+
private static final String MESSAGE = "Replace this call with \"%s.sleep_forever()\" as the sleep duration exceeds 24 hours.";
39+
private static final int SECONDS_IN_DAY = 86400;
40+
41+
private TypeCheckBuilder isTrioSleepCall;
42+
private TypeCheckBuilder isAnyioSleepCall;
43+
private final Map<String, String> asyncLibraryAliases = new HashMap<>();
44+
45+
@Override
46+
public void initialize(Context context) {
47+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupCheck);
48+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkAsyncLongSleep);
49+
}
50+
51+
private void setupCheck(SubscriptionContext ctx) {
52+
isTrioSleepCall = ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("trio.sleep");
53+
isAnyioSleepCall = ctx.typeChecker().typeCheckBuilder().isTypeWithName("anyio.sleep");
54+
asyncLibraryAliases.clear();
55+
}
56+
57+
private void checkAsyncLongSleep(SubscriptionContext context) {
58+
CallExpression callExpression = (CallExpression) context.syntaxNode();
59+
Expression callee = callExpression.callee();
60+
61+
String libraryName;
62+
if (isTrioSleepCall.check(callee.typeV2()) == TriBool.TRUE) {
63+
libraryName = "trio";
64+
} else if (isAnyioSleepCall.check(callee.typeV2()) == TriBool.TRUE) {
65+
libraryName = "anyio";
66+
} else {
67+
return;
68+
}
69+
70+
Token asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(callExpression).orElse(null);
71+
if (asyncToken == null) {
72+
return;
73+
}
74+
75+
Expression durationExpr = extractDurationExpression(callExpression, "trio".equals(libraryName) ? "seconds" : "delay").orElse(null);
76+
if (durationExpr == null) {
77+
return;
78+
}
79+
if (CommonValidationUtils.isMoreThan(durationExpr, SECONDS_IN_DAY)) {
80+
String libraryAlias = asyncLibraryAliases.getOrDefault(libraryName, libraryName);
81+
String message = String.format(MESSAGE, libraryAlias);
82+
context.addIssue(callExpression, message).secondary(asyncToken, "This function is async.");
83+
}
84+
}
85+
86+
private static Optional<Expression> extractDurationExpression(CallExpression callExpression, String paramName) {
87+
RegularArgument durationArgument = TreeUtils.nthArgumentOrKeyword(0, paramName, callExpression.arguments());
88+
return durationArgument != null ? Optional.of(durationArgument.expression()) : Optional.empty();
89+
}
90+
}

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
@@ -122,6 +122,7 @@ public Stream<Class<?>> getChecks() {
122122
AssertAfterRaiseCheck.class,
123123
AssertOnTupleLiteralCheck.class,
124124
AsyncFunctionWithTimeoutCheck.class,
125+
AsyncLongSleepCheck.class,
125126
BackslashInStringCheck.class,
126127
BackticksUsageCheck.class,
127128
BareRaiseInFinallyCheck.class,

python-checks/src/main/java/org/sonar/python/checks/hotspots/CommonValidationUtils.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ static boolean isLessThan(Expression expression, int number) {
4646
}
4747
}
4848

49+
public static boolean isMoreThan(Expression expression, int number) {
50+
try {
51+
if (expression.is(Tree.Kind.NAME)) {
52+
return Expressions.singleAssignedNonNameValue(((Name) expression)).map(value -> isMoreThan(value, number)).orElse(false);
53+
}
54+
return expression.is(Tree.Kind.NUMERIC_LITERAL) && ((NumericLiteral) expression).valueAsLong() > number;
55+
} catch (NumberFormatException nfe) {
56+
return false;
57+
}
58+
}
59+
4960
static boolean isLessThanExponent(Expression expression, int exponent) {
5061
if (expression.is(Tree.Kind.SHIFT_EXPR)) {
5162
var shiftExpression = (BinaryExpression) expression;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<p>This rules raises an issue when <code>trio.sleep()</code> or <code>anyio.sleep()</code> is called with a duration greater than 24 hours.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>When using <code>trio.sleep()</code> or <code>anyio.sleep()</code> with very long intervals (greater than 24 hours), the intent is usually to wait
4+
indefinitely or for an extremely long period. Both libraries provide dedicated methods specifically designed for this purpose:
5+
<code>trio.sleep_forever()</code> and <code>anyio.sleep_forever()</code>.</p>
6+
<p>Using explicit sleep durations greater than 24 hours has several drawbacks:</p>
7+
<ul>
8+
<li> It obscures the developer’s intent. A very large number like <code>86400 * 365</code> doesn’t clearly communicate that the code intends to wait
9+
indefinitely. </li>
10+
<li> It makes code less maintainable, as other developers need to calculate what the large numbers actually represent. </li>
11+
</ul>
12+
<p>In summary, using <code>sleep_forever()</code> is preferable when the intent is to sleep indefinitely as it clearly conveys this purpose, avoiding
13+
maintainability issues caused by using arbitrarily large sleep durations.</p>
14+
<h2>How to fix it in Trio</h2>
15+
<p>Replace calls to <code>trio.sleep()</code> that use intervals greater than 24 hours with calls to <code>trio.sleep_forever()</code>.</p>
16+
<p>If the intention is truly to wait for a specific long duration rather than indefinitely, consider expressing that intent more clearly by using
17+
named variables or constants.</p>
18+
<h3>Code examples</h3>
19+
<h4>Noncompliant code example</h4>
20+
<pre data-diff-id="1" data-diff-type="noncompliant">
21+
import trio
22+
23+
async def long_wait():
24+
await trio.sleep(86400 * 365) # Noncompliant
25+
</pre>
26+
<h4>Compliant solution</h4>
27+
<pre data-diff-id="1" data-diff-type="compliant">
28+
import trio
29+
30+
async def long_wait():
31+
await trio.sleep_forever()
32+
</pre>
33+
<h2>How to fix it in AnyIO</h2>
34+
<p>Replace calls to <code>anyio.sleep()</code> that use intervals greater than 24 hours with calls to <code>anyio.sleep_forever()</code>.</p>
35+
<p>If the intention is truly to wait for a specific long duration rather than indefinitely, consider expressing that intent more clearly by using
36+
named variables or constants.</p>
37+
<h3>Code examples</h3>
38+
<h4>Noncompliant code example</h4>
39+
<pre data-diff-id="2" data-diff-type="noncompliant">
40+
import anyio
41+
42+
async def long_wait():
43+
await anyio.sleep(86400 * 30) # Noncompliant
44+
</pre>
45+
<h4>Compliant solution</h4>
46+
<pre data-diff-id="2" data-diff-type="compliant">
47+
import anyio
48+
49+
async def long_wait():
50+
await anyio.sleep_forever()
51+
</pre>
52+
<h2>Resources</h2>
53+
<h3>Documentation</h3>
54+
<ul>
55+
<li> Trio - <a href="https://trio.readthedocs.io/en/stable/reference-core.html#trio.sleep_forever">sleep_forever() documentation</a> </li>
56+
<li> AnyIO - <a href="https://anyio.readthedocs.io/en/stable/api.html#anyio.sleep_forever">sleep_forever() documentation</a> </li>
57+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-task.html">Tasks and coroutines</a> </li>
58+
</ul>
59+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Long sleep durations should use sleep_forever() instead of arbitrary intervals",
3+
"type": "CODE_SMELL",
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": "Minor",
16+
"ruleSpecification": "RSPEC-7486",
17+
"sqKey": "S7486",
18+
"scope": "All",
19+
"quickfix": "unknown",
20+
"code": {
21+
"impacts": {
22+
"MAINTAINABILITY": "LOW"
23+
},
24+
"attribute": "CONVENTIONAL"
25+
}
26+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@
262262
"S7498",
263263
"S7499",
264264
"S7501",
265-
"S7510"
265+
"S7510",
266+
"S7486",
267+
"S7501"
266268
]
267269
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 AsyncLongSleepCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/asyncLongSleep.py", new AsyncLongSleepCheck());
27+
}
28+
29+
}

python-checks/src/test/java/org/sonar/python/checks/hotspots/CommonValidationUtilsTest.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@
2727

2828
class CommonValidationUtilsTest {
2929

30-
class isLessThanTestCheck extends PythonSubscriptionCheck {
30+
static class isLessThanMoreThanTestCheck extends PythonSubscriptionCheck {
3131
@Override
3232
public void initialize(Context context) {
33-
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, isLessThanTestCheck::checkCallExpr);
33+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, isLessThanMoreThanTestCheck::checkCallExpr);
3434
}
3535

3636
private static void checkCallExpr(SubscriptionContext ctx) {
@@ -40,6 +40,9 @@ private static void checkCallExpr(SubscriptionContext ctx) {
4040
if (CommonValidationUtils.isLessThan(argument.expression(), 10)) {
4141
ctx.addIssue(argument, "Argument is less than 10");
4242
}
43+
if (CommonValidationUtils.isMoreThan(argument.expression(), 42)) {
44+
ctx.addIssue(argument, "Argument is more than 42");
45+
}
4346
});
4447
TreeUtils.nthArgumentOrKeywordOptional(1, "isEqualTo", callExpression.arguments())
4548
.ifPresent(argument -> {
@@ -52,6 +55,6 @@ private static void checkCallExpr(SubscriptionContext ctx) {
5255

5356
@Test
5457
void isLessThan() {
55-
PythonCheckVerifier.verify("src/test/resources/checks/commonValidationUtils.py", new isLessThanTestCheck());
58+
PythonCheckVerifier.verify("src/test/resources/checks/commonValidationUtils.py", new isLessThanMoreThanTestCheck());
5659
}
57-
}
60+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import trio
2+
import anyio
3+
import trio as t
4+
import anyio as a
5+
6+
async def long_sleep_trio():
7+
await trio.sleep(86401) # Noncompliant
8+
9+
async def long_sleep_trio():
10+
await trio.sleep() # OK
11+
12+
async def long_sleep_anyio():
13+
await anyio.sleep(86401) # Noncompliant {{Replace this call with "anyio.sleep_forever()" as the sleep duration exceeds 24 hours.}}
14+
15+
async def long_sleep_trio_arithmetic():
16+
await trio.sleep(86400 * 2) # FN
17+
18+
async def long_sleep_with_arithmetic_2():
19+
days = 30
20+
await trio.sleep(86400 * days) # FN
21+
22+
async def long_sleep_with_alias():
23+
await t.sleep(86401) # Noncompliant {{Replace this call with "trio.sleep_forever()" as the sleep duration exceeds 24 hours.}}
24+
await a.sleep(86401) # Noncompliant {{Replace this call with "anyio.sleep_forever()" as the sleep duration exceeds 24 hours.}}
25+
26+
# Compliant cases - duration <= 24 hours
27+
async def normal_sleep_trio():
28+
await trio.sleep(3600) # Compliant - one hour
29+
30+
async def normal_sleep_anyio():
31+
await anyio.sleep(86400) # Compliant - exactly 24 hours
32+
33+
def not_async_function():
34+
# Not in async context, so not reported
35+
trio.sleep(86400 * 10) # Compliant - not in async context
36+
37+
# Edge cases
38+
async def with_complex_expressions():
39+
x = get_duration()
40+
await trio.sleep(x) # Compliant - can't determine value statically
41+
42+
# Complex expressions that can be evaluated
43+
await anyio.sleep(24 * 60 * 60 * 2) # FN
44+
await trio.sleep(86400 / 0.5) # FN
45+
46+
async def sleep_forever():
47+
# Already using the recommended alternatives
48+
await trio.sleep_forever() # Compliant
49+
await anyio.sleep_forever() # Compliant

python-checks/src/test/resources/checks/commonValidationUtils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@
1717
not_ten = 11
1818
callExpr(isEqualTo=not_ten)
1919
callExpr(12, var_wrong)
20+
21+
var_too_big = 100
22+
callExpr(var_too_big) # Noncompliant {{Argument is more than 42}}
23+
callExpr(45) # Noncompliant {{Argument is more than 42}}
24+
25+
callExpr(2e64)

0 commit comments

Comments
 (0)