Skip to content

Commit 0b4f278

Browse files
authored
SONARPY-1644 Rule S6890: zoneinfo should be preferred to pytz when using Python 3.9 and later (#1738)
1 parent 9b77bce commit 0b4f278

File tree

7 files changed

+214
-2
lines changed

7 files changed

+214
-2
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
@@ -301,6 +301,7 @@ public static Iterable<Class> getChecks() {
301301
PrivilegePolicyCheck.class,
302302
ProcessSignallingCheck.class,
303303
PropertyAccessorParameterCountCheck.class,
304+
PytzUsageCheck.class,
304305
IncorrectParameterDatetimeConstructorsCheck.class,
305306
PseudoRandomCheck.class,
306307
PublicApiIsSecuritySensitiveCheck.class,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 java.util.Collection;
23+
import java.util.Optional;
24+
import java.util.stream.Stream;
25+
import org.sonar.check.Rule;
26+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
27+
import org.sonar.plugins.python.api.PythonVersionUtils;
28+
import org.sonar.plugins.python.api.SubscriptionContext;
29+
import org.sonar.plugins.python.api.symbols.Symbol;
30+
import org.sonar.plugins.python.api.tree.AliasedName;
31+
import org.sonar.plugins.python.api.tree.DottedName;
32+
import org.sonar.plugins.python.api.tree.ImportFrom;
33+
import org.sonar.plugins.python.api.tree.ImportName;
34+
import org.sonar.plugins.python.api.tree.Tree;
35+
import org.sonar.python.tree.TreeUtils;
36+
37+
@Rule(key = "S6890")
38+
public class PytzUsageCheck extends PythonSubscriptionCheck {
39+
private static final PythonVersionUtils.Version REQUIRED_VERSION = PythonVersionUtils.Version.V_39;
40+
private static final String MESSAGE = "Don't use `pytz` module with Python 3.9 and later.";
41+
42+
@Override
43+
public void initialize(Context context) {
44+
context.registerSyntaxNodeConsumer(Tree.Kind.IMPORT_FROM, PytzUsageCheck::checkImport);
45+
context.registerSyntaxNodeConsumer(Tree.Kind.IMPORT_NAME, PytzUsageCheck::checkImport);
46+
}
47+
48+
private static boolean isRelevantPythonVersion(SubscriptionContext context) {
49+
return context.sourcePythonVersions().stream()
50+
.allMatch(version -> version.compare(REQUIRED_VERSION.major(), REQUIRED_VERSION.minor()) >= 0);
51+
}
52+
53+
private static void checkImport(SubscriptionContext context) {
54+
if (!isRelevantPythonVersion(context)) {
55+
return;
56+
}
57+
58+
Stream.of(
59+
Optional.of(context.syntaxNode())
60+
.flatMap(TreeUtils.toOptionalInstanceOfMapper(ImportFrom.class))
61+
.map(ImportFrom::importedNames),
62+
Optional.of(context.syntaxNode())
63+
.flatMap(TreeUtils.toOptionalInstanceOfMapper(ImportName.class))
64+
.map(ImportName::modules))
65+
.filter(Optional::isPresent)
66+
.map(Optional::get)
67+
.flatMap(Collection::stream)
68+
.map(AliasedName::dottedName)
69+
.map(DottedName::names)
70+
.filter(list -> !list.isEmpty())
71+
.map(names -> names.get(0))
72+
.filter(name -> "pytz".equals(name.name())
73+
|| Optional.ofNullable(name.symbol())
74+
.map(Symbol::fullyQualifiedName)
75+
.filter(fqn -> fqn.startsWith("pytz")).isPresent())
76+
.forEach(name -> raiseIssue(context, name));
77+
}
78+
79+
private static void raiseIssue(SubscriptionContext context, Tree tree) {
80+
if (context.syntaxNode().is(Tree.Kind.IMPORT_FROM)) {
81+
ImportFrom importFrom = (ImportFrom) context.syntaxNode();
82+
Optional.ofNullable(importFrom.module()).ifPresent(module -> context.addIssue(module, MESSAGE));
83+
} else {
84+
context.addIssue(tree, MESSAGE);
85+
}
86+
}
87+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<p>This rule raises an issue when using the <code>pytz</code> library on a codebase using Python 3.9 or later.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>In Python 3.9 and later, the <code>zoneinfo</code> module is the recommended tool for handling timezones, replacing the <code>pytz</code> library.
4+
This recommendation is based on several key advantages.</p>
5+
<p>First, <code>zoneinfo</code> is part of Python’s standard library, making it readily available without needing additional installation, unlike
6+
<code>pytz</code>.</p>
7+
<p>Second, <code>zoneinfo</code> integrates seamlessly with Python’s datetime module. You can directly use <code>zoneinfo</code> timezone objects when
8+
creating <code>datetime</code> objects, making it more intuitive and less error-prone than <code>pytz</code>, which requires a separate localize
9+
method for this purpose.</p>
10+
<p>Third, <code>zoneinfo</code> handles historical timezone changes more accurately than <code>pytz</code>. When a <code>pytz</code> timezone object
11+
is used, it defaults to the earliest known offset, which can lead to unexpected results. <code>zoneinfo</code> does not have this issue.</p>
12+
<p>Lastly, <code>zoneinfo</code> uses the system’s IANA time zone database when available, ensuring it works with the most up-to-date timezone data.
13+
In contrast, <code>pytz</code> includes its own copy of the IANA database, which may not be as current.</p>
14+
<p>In summary, <code>zoneinfo</code> offers a more modern, intuitive, and reliable approach to handling timezones in Python 3.9 and later, making it
15+
the preferred choice over <code>pytz</code>.</p>
16+
<h2>How to fix it</h2>
17+
<p>To fix this is issue use a <code>zoneinfo</code> timezone object when constructing a <code>datetime</code> instead of the <code>pytz</code>
18+
library.</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 datetime
23+
import pytz
24+
25+
dt = pytz.timezone('America/New_York'').localize(datetime(2022, 1, 1)) # Noncompliant: the localize method is needed to avoid bugs (see S6887)
26+
</pre>
27+
<h4>Compliant solution</h4>
28+
<pre data-diff-id="1" data-diff-type="compliant">
29+
from datetime import datetime
30+
from zoneinfo import ZoneInfo
31+
32+
dt = datetime(2022, 1, 1, tzinfo=ZoneInfo('America/New_York')) # OK: timezone object can be used safely through the datetime constructor
33+
</pre>
34+
<h2>Resources</h2>
35+
<h3>Documentation</h3>
36+
<ul>
37+
<li> PEP 615 - <a href="https://peps.python.org/pep-0615/">Support for the IANA Time Zone Database in the Standard Library</a> </li>
38+
</ul>
39+
<h3>Related rules</h3>
40+
<ul>
41+
<li> {rule:python:S6887} - pytz.timezone should not be passed to the datetime.datetime constructor </li>
42+
</ul>
43+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"title": "zoneinfo should be preferred to pytz when using Python 3.9 and later",
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-6890",
12+
"sqKey": "S6890",
13+
"scope": "All",
14+
"quickfix": "unknown",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "HIGH",
18+
"RELIABILITY": "MEDIUM",
19+
"SECURITY": "LOW"
20+
},
21+
"attribute": "CONVENTIONAL"
22+
}
23+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@
228228
"S6882",
229229
"S6883",
230230
"S6887",
231-
"S6903",
232-
"S6894"
231+
"S6890",
232+
"S6894",
233+
"S6903"
233234
]
234235
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 java.util.EnumSet;
23+
import org.junit.jupiter.api.Test;
24+
import org.sonar.plugins.python.api.ProjectPythonVersion;
25+
import org.sonar.plugins.python.api.PythonVersionUtils;
26+
import org.sonar.python.checks.utils.PythonCheckVerifier;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
class PytzUsageCheckTest {
31+
@Test
32+
void test_38() {
33+
ProjectPythonVersion.setCurrentVersions(EnumSet.of(PythonVersionUtils.Version.V_38));
34+
var issues = PythonCheckVerifier.issues("src/test/resources/checks/pytzUsage.py", new PytzUsageCheck());
35+
assertThat(issues)
36+
.isEmpty();
37+
}
38+
39+
@Test
40+
void test_39_310_311_312() {
41+
ProjectPythonVersion
42+
.setCurrentVersions(EnumSet.of(PythonVersionUtils.Version.V_39, PythonVersionUtils.Version.V_310, PythonVersionUtils.Version.V_311, PythonVersionUtils.Version.V_312));
43+
PythonCheckVerifier.verify("src/test/resources/checks/pytzUsage.py", new PytzUsageCheck());
44+
}
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from datetime import datetime
2+
3+
def on_import():
4+
import pytz # Noncompliant {{Don't use `pytz` module with Python 3.9 and later.}}
5+
#^^^^
6+
from pytz import timezone # Noncompliant {{Don't use `pytz` module with Python 3.9 and later.}}
7+
#^^^^
8+
import pytz as p # Noncompliant {{Don't use `pytz` module with Python 3.9 and later.}}
9+
#^^^^
10+
from something.different.pytz import a_function
11+
import something.different.pytz as p2
12+
import pytz as p3 # Noncompliant {{Don't use `pytz` module with Python 3.9 and later.}}

0 commit comments

Comments
 (0)