Skip to content

Commit 9b77bce

Browse files
authored
SONARPY-1640 S6883: The 12-hour format should be used with the AM/PM marker, otherwise 24-hour format should be used (#1736)
1 parent 79e4649 commit 9b77bce

File tree

7 files changed

+237
-0
lines changed

7 files changed

+237
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ public static Iterable<Class> getChecks() {
338338
SpecialMethodReturnTypeCheck.class,
339339
SQLQueriesCheck.class,
340340
StandardInputCheck.class,
341+
StrftimeConfusingHourSystemCheck.class,
341342
StringFormatCorrectnessCheck.class,
342343
StringFormatMisuseCheck.class,
343344
StringLiteralDuplicationCheck.class,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2024 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks;
21+
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.symbols.Symbol;
26+
import org.sonar.plugins.python.api.tree.CallExpression;
27+
import org.sonar.plugins.python.api.tree.Expression;
28+
import org.sonar.plugins.python.api.tree.RegularArgument;
29+
import org.sonar.plugins.python.api.tree.StringLiteral;
30+
import org.sonar.plugins.python.api.tree.Tree;
31+
import org.sonar.python.checks.utils.Expressions;
32+
import org.sonar.python.tree.TreeUtils;
33+
34+
@Rule(key = "S6883")
35+
public class StrftimeConfusingHourSystemCheck extends PythonSubscriptionCheck {
36+
37+
public static final String MESSAGE = "Use %I (12-hour clock) or %H (24-hour clock) without %p (AM/PM).";
38+
public static final String MESSAGE_12_HOURS = "Use %I (12-hour clock) with %p (AM/PM).";
39+
private static final String MESSAGE_SECONDARY_LOCATION = "Wrong format created here.";
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, StrftimeConfusingHourSystemCheck::checkCallExpr);
44+
}
45+
46+
private static void checkExpression(SubscriptionContext context, Expression expression) {
47+
checkExpression(context, expression, expression);
48+
}
49+
50+
private static void checkExpression(SubscriptionContext context, Expression expression, Tree primaryLocation) {
51+
Expressions.ifNameGetSingleAssignedNonNameValue(expression)
52+
.filter(StringLiteral.class::isInstance)
53+
.map(StringLiteral.class::cast)
54+
.filter(stringLiteral -> stringLiteral.stringElements().stream().noneMatch(stringElement -> "f".equalsIgnoreCase(stringElement.prefix())))
55+
.ifPresent(stringLiteral -> checkDateFormatStringLiteral(context, primaryLocation, stringLiteral));
56+
}
57+
58+
private static void checkDateFormatStringLiteral(SubscriptionContext context, Tree primaryLocation, StringLiteral stringLiteral) {
59+
String value = stringLiteral.trimmedQuotesValue();
60+
String effectiveMessage = null;
61+
if (value.contains("%H") && value.contains("%p")) {
62+
effectiveMessage = MESSAGE;
63+
} else if (value.contains("%I") && !value.contains("%p")) {
64+
effectiveMessage = MESSAGE_12_HOURS;
65+
}
66+
if (effectiveMessage != null) {
67+
var issue = context.addIssue(primaryLocation, effectiveMessage);
68+
if (primaryLocation != stringLiteral) {
69+
issue.secondary(stringLiteral, MESSAGE_SECONDARY_LOCATION);
70+
}
71+
}
72+
}
73+
74+
private static void checkCallExpr(SubscriptionContext context) {
75+
CallExpression callExpression = (CallExpression) context.syntaxNode();
76+
Symbol calleeSymbol = callExpression.calleeSymbol();
77+
if (calleeSymbol == null) {
78+
return;
79+
}
80+
String fullyQualifiedName = calleeSymbol.fullyQualifiedName();
81+
if (!"datetime.time.strftime".equals(fullyQualifiedName)) {
82+
return;
83+
}
84+
85+
RegularArgument formatArgument = TreeUtils.nthArgumentOrKeyword(0, "format", callExpression.arguments());
86+
if (formatArgument == null) {
87+
return;
88+
}
89+
checkExpression(context, formatArgument.expression());
90+
}
91+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<p>This rule raises an issue when a date format string has the 24-hour code with the AM/PM marker or the 12-hour code without the AM/PM marker.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>The <code>%p</code> directive in the <code>strftime</code> method is used to represent the AM/PM marker in a time string. It is commonly used in
4+
conjunction with the <code>%I</code> directive, which represents the hour in a 12-hour clock.</p>
5+
<p>Using the 24-hour format directive with an AM/PM marker can lead to unwanted results e.g.:</p>
6+
<pre>
7+
time_string = time(16,0).strftime("%H:%M %p")
8+
print(time_string)
9+
</pre>
10+
<p>will print <code>16:00 PM</code> which makes no sense.</p>
11+
<p>On the other hand the AM/PM marker is needed when the 12-hour format directive is used to show complete information about an hour e.g.:</p>
12+
<pre>
13+
time_string = time(16,0).strftime("%I:%M")
14+
print(time_string)
15+
</pre>
16+
<p>will print 04:00 without indicating if the time is in the morning or the afternoon.</p>
17+
<h2>How to fix it</h2>
18+
<p>Use either the 12-hour time format with an AM/PM marker or the 24-hour format without.</p>
19+
<h3>Code examples</h3>
20+
<h4>Noncompliant code example</h4>
21+
<pre data-diff-id="1" data-diff-type="noncompliant">
22+
from datetime import time
23+
24+
def foo():
25+
t = time(16, 0)
26+
formatted_time1 = t.strftime("%H:%M %p") # Noncompliant: 16:00 PM
27+
formatted_time2 = t.strftime("%I:%M") # Noncompliant: 04:00
28+
</pre>
29+
<h4>Compliant solution</h4>
30+
<pre data-diff-id="1" data-diff-type="compliant">
31+
from datetime import time
32+
33+
def foo():
34+
t = time(16, 0)
35+
formatted_time1 = t.strftime("%I:%M %p") # OK: 04:00 PM
36+
formatted_time2 = t.strftime("%H:%M") # OK: 16:00
37+
</pre>
38+
<h2>Resources</h2>
39+
<h3>Documentation</h3>
40+
<ul>
41+
<li> Python documentation - <a href="https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes">strftime() and strptime()
42+
Format Codes</a> </li>
43+
</ul>
44+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"title": "The 12-hour format should be used with the AM\/PM marker, otherwise the 24-hour format should be used",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Major",
11+
"ruleSpecification": "RSPEC-6883",
12+
"sqKey": "S6883",
13+
"scope": "All",
14+
"quickfix": "unknown",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "HIGH",
18+
"RELIABILITY": "MEDIUM"
19+
},
20+
"attribute": "CONVENTIONAL"
21+
}
22+
}

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
@@ -226,6 +226,7 @@
226226
"S6796",
227227
"S6799",
228228
"S6882",
229+
"S6883",
229230
"S6887",
230231
"S6903",
231232
"S6894"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2024 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks;
21+
22+
import org.junit.jupiter.api.Test;
23+
import org.sonar.python.checks.utils.PythonCheckVerifier;
24+
25+
class StrftimeConfusingHourSystemCheckTest {
26+
@Test
27+
void test() {
28+
PythonCheckVerifier.verify("src/test/resources/checks/strftimeConfusingHourSystem.py", new StrftimeConfusingHourSystemCheck());
29+
}
30+
31+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
2+
unrelated_unknown_function()
3+
def simple_cases():
4+
from datetime import time
5+
t = time(16, 0)
6+
formatted_time1 = t.strftime("%H:%M %p") # Noncompliant {{Use %I (12-hour clock) or %H (24-hour clock) without %p (AM/PM).}}
7+
#^^^^^^^^^^
8+
formatted_time2 = t.strftime("%I:%M") # Noncompliant {{Use %I (12-hour clock) with %p (AM/PM).}}
9+
#^^^^^^^
10+
formatted_time3 = t.strftime("%I:%M %p")
11+
formatted_time4 = t.strftime("%H:%M")
12+
formatted_time5 = t.strftime()
13+
formatted_time6 = t.strftime(True)
14+
formatted_time7 = t.strftime("No formatting")
15+
formatted_time8 = t.strftime("%I:%M:%S %p")
16+
formatted_time9 = t.strftime("%I:%M:%S %p %z")
17+
formatted_time10 = t.strftime("%I:%M:%S %p %Z")
18+
formatted_time11 = t.strftime("%I:%M:%S %p %f")
19+
formatted_time12 = t.strftime("%I:%M:%S %p %j")
20+
formatted_time13 = t.strftime("%I:%M:%S %p %U")
21+
formatted_time14 = t.strftime("%I:%M:%S %p %W")
22+
formatted_time15 = t.strftime("%I:%M:%S %p %c")
23+
formatted_time16 = t.strftime("%I:%M:%S %p %x")
24+
formatted_time17 = t.strftime("%I:%M:%S %p %X")
25+
formatted_time18 = t.strftime("%I:%M:%S %p %G")
26+
formatted_time19 = t.strftime(" %p %H %M") # Noncompliant {{Use %I (12-hour clock) or %H (24-hour clock) without %p (AM/PM).}}
27+
#^^^^^^^^^^^
28+
formatted_time20 = t.strftime("Hour :%I , minutes : %M") # Noncompliant {{Use %I (12-hour clock) with %p (AM/PM).}}
29+
formatted_time21 = t.strftime("Hour :%H , minutes : %M , useless : %p") # Noncompliant {{Use %I (12-hour clock) or %H (24-hour clock) without %p (AM/PM).}}
30+
format1 = "%H:%M %p"
31+
#^^^^^^^^^^> 1 {{Wrong format created here.}}
32+
t.strftime(format1) # Noncompliant {{Use %I (12-hour clock) or %H (24-hour clock) without %p (AM/PM).}}
33+
#^^^^^^^
34+
35+
if cond():
36+
format2 = "%I:%M"
37+
else:
38+
format2 = "%H:%M"
39+
t.strftime(format2) # FN because of the limitations of SingleAssignedValue
40+
41+
# This one is a FN because we don't support f-strings in this rule
42+
format19 = f"%I:%M"
43+
t.strftime(format19)
44+
45+
# This one is a FN because we don't support f-strings in this rule
46+
format_marker = "%p"
47+
t.strftime(f"%H:%M {format_marker}")

0 commit comments

Comments
 (0)