Skip to content

Commit

Permalink
Add simplify-fromisoformat check (#210)
Browse files Browse the repository at this point in the history
  • Loading branch information
dosisod committed Feb 21, 2023
1 parent 143ed81 commit ba3738d
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 0 deletions.
5 changes: 5 additions & 0 deletions docs/categories.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ and so on.
These checks are for the [contextlib](https://docs.python.org/3/library/contextlib.html)
standard library module.

## `datetime`

These checks are for the [datetime](https://docs.python.org/3/library/datetime.html)
standard library module.

## `decimal`

These checks are for the [decimal](https://docs.python.org/3/library/decimal.html)
Expand Down
Empty file.
125 changes: 125 additions & 0 deletions refurb/checks/datetime/simplify_fromisoformat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from dataclasses import dataclass

from mypy.nodes import (
CallExpr,
Expression,
IndexExpr,
IntExpr,
MemberExpr,
NameExpr,
OpExpr,
SliceExpr,
StrExpr,
UnaryExpr,
Var,
)

from refurb.error import Error
from refurb.settings import Settings


@dataclass
class ErrorInfo(Error):
"""
Python 3.11 adds support for parsing UTC timestamps that end with `Z`, thus
removing the need to strip and append the `+00:00` timezone.
Bad:
```
date = "2023-02-21T02:23:15Z"
start_date = datetime.fromisoformat(date.replace("Z", "+00:00"))
```
Good:
```
date = "2023-02-21T02:23:15Z"
start_date = datetime.fromisoformat(date)
```
"""

name = "simplify-fromisoformat"
code = 162
categories = ["datetime", "readability"]


def is_string(node: Expression) -> bool:
match node:
case StrExpr():
return True

case NameExpr(node=Var(type=ty)) if str(ty) == "builtins.str":
return True

return False


def is_utc_timezone(timezone: str) -> bool:
return timezone.startswith(("+", "-")) and timezone.strip("+-") in (
"00:00",
"0000",
"00",
)


def check(node: CallExpr, errors: list[Error], settings: Settings) -> None:
if not settings.python_version or settings.python_version < (3, 11):
return

match node:
case CallExpr(
callee=MemberExpr(
expr=NameExpr(fullname="datetime.datetime"),
name="fromisoformat",
),
args=[arg],
):
match arg:
case CallExpr(
callee=MemberExpr(expr=date, name="replace"),
args=[
StrExpr(value="Z"),
StrExpr(value=timezone),
],
) if is_string(date) and is_utc_timezone(timezone):
old = f'fromisoformat(x.replace("Z", "{timezone}"))'

case OpExpr(
left=IndexExpr(
base=date,
index=SliceExpr(
begin_index=None,
end_index=UnaryExpr(op="-", expr=IntExpr(value=1)),
stride=None,
),
),
op="+",
right=StrExpr(value=timezone),
) if is_string(date) and is_utc_timezone(timezone):
old = f'fromisoformat(x[:-1] + "{timezone}")'

case OpExpr(
left=CallExpr(
callee=MemberExpr(
expr=date, name="strip" | "rstrip" as func_name
),
args=[StrExpr(value="Z")],
),
op="+",
right=StrExpr(value=timezone),
) if is_string(date) and is_utc_timezone(timezone):
old = f'fromisoformat(x.{func_name}("Z") + "{timezone}")'

case _:
return

errors.append(
ErrorInfo(
node.line,
node.column,
f"Replace `{old}` with `fromisoformat(x)`",
)
)
34 changes: 34 additions & 0 deletions test/data_3.11/err_162.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from datetime import datetime

# these should match

datetime.fromisoformat("".replace("Z", "+00:00"))
datetime.fromisoformat("".replace("Z", "-00:00"))
datetime.fromisoformat("".replace("Z", "+0000"))
datetime.fromisoformat("".replace("Z", "-0000"))
datetime.fromisoformat("".replace("Z", "+00"))
datetime.fromisoformat("".replace("Z", "-00"))

x = ""
datetime.fromisoformat(x.replace("Z", "+00:00"))

datetime.fromisoformat(""[:-1] + "+00:00")
datetime.fromisoformat("".strip("Z") + "+00:00")
datetime.fromisoformat("".rstrip("Z") + "+00:00")


# these should not

datetime.fromisoformat("".replace("XYZ", "+00:00"))
datetime.fromisoformat("".replace("Z", "+10:00"))

datetime.fromisoformat(""[:1] + "+00:00")
datetime.fromisoformat(""[1:1] + "+00:00")
datetime.fromisoformat(""[:-1:1] + "+00:00")

class C:
def replace(self, this: str, that: str) -> str:
return this + that

c = C()
datetime.fromisoformat(c.replace("Z", "+00:00"))
10 changes: 10 additions & 0 deletions test/data_3.11/err_162.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
test/data_3.11/err_162.py:5:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "+00:00"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:6:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "-00:00"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:7:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "+0000"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:8:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "-0000"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:9:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "+00"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:10:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "-00"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:13:1 [FURB162]: Replace `fromisoformat(x.replace("Z", "+00:00"))` with `fromisoformat(x)`
test/data_3.11/err_162.py:15:1 [FURB162]: Replace `fromisoformat(x[:-1] + "+00:00")` with `fromisoformat(x)`
test/data_3.11/err_162.py:16:1 [FURB162]: Replace `fromisoformat(x.strip("Z") + "+00:00")` with `fromisoformat(x)`
test/data_3.11/err_162.py:17:1 [FURB162]: Replace `fromisoformat(x.rstrip("Z") + "+00:00")` with `fromisoformat(x)`
2 changes: 2 additions & 0 deletions test/test_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def test_error_ignored_if_category_matches() -> None:
def test_checks_with_python_version_dependant_error_msgs() -> None:
run_checks_in_folder(Path("test/data_3.10"), version=(3, 10))

run_checks_in_folder(Path("test/data_3.11"), version=(3, 11))


def run_checks_in_folder(
folder: Path, *, version: tuple[int, int] | None = None
Expand Down

0 comments on commit ba3738d

Please sign in to comment.