Skip to content

Commit

Permalink
use zoneinfo instead of pytz where possible (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin authored Nov 15, 2023
1 parent aa7ff03 commit cb2b800
Show file tree
Hide file tree
Showing 10 changed files with 104 additions and 50 deletions.
75 changes: 46 additions & 29 deletions dirty_equals/_datetime.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations as _annotations

from datetime import date, datetime, timedelta, timezone, tzinfo
from typing import Any, Optional, Union
from typing import TYPE_CHECKING, Any

from ._numeric import IsNumeric
from ._utils import Omit

if TYPE_CHECKING:
from zoneinfo import ZoneInfo


class IsDatetime(IsNumeric[datetime]):
"""
Expand All @@ -15,15 +20,15 @@ class IsDatetime(IsNumeric[datetime]):
def __init__(
self,
*,
approx: Optional[datetime] = None,
delta: Optional[Union[timedelta, int, float]] = None,
gt: Optional[datetime] = None,
lt: Optional[datetime] = None,
ge: Optional[datetime] = None,
le: Optional[datetime] = None,
approx: datetime | None = None,
delta: timedelta | int | float | None = None,
gt: datetime | None = None,
lt: datetime | None = None,
ge: datetime | None = None,
le: datetime | None = None,
unix_number: bool = False,
iso_string: bool = False,
format_string: Optional[str] = None,
format_string: str | None = None,
enforce_tz: bool = True,
):
"""
Expand Down Expand Up @@ -117,6 +122,24 @@ def approx_equals(self, other: datetime, delta: timedelta) -> bool:
return True


def _zoneinfo(tz: str) -> ZoneInfo:
"""
Instantiate a `ZoneInfo` object from a string, falling back to `pytz.timezone` when `ZoneInfo` is not available
(most likely on Python 3.8 and webassembly).
"""
try:
from zoneinfo import ZoneInfo
except ImportError:
try:
import pytz
except ImportError as e:
raise ImportError('`pytz` or `zoneinfo` required for tz handling') from e
else:
return pytz.timezone(tz) # type: ignore[return-value]
else:
return ZoneInfo(tz)


class IsNow(IsDatetime):
"""
Check if a datetime is close to now, this is similar to `IsDatetime(approx=datetime.now())`,
Expand All @@ -126,12 +149,12 @@ class IsNow(IsDatetime):
def __init__(
self,
*,
delta: Union[timedelta, int, float] = 2,
delta: timedelta | int | float = 2,
unix_number: bool = False,
iso_string: bool = False,
format_string: Optional[str] = None,
format_string: str | None = None,
enforce_tz: bool = True,
tz: Union[None, str, tzinfo] = None,
tz: str | tzinfo | None = None,
):
"""
Args:
Expand All @@ -141,7 +164,8 @@ def __init__(
iso_string: whether to allow iso formatted strings in comparison
format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings
enforce_tz: whether timezone should be enforced in comparison, see below for more details
tz: either a `pytz.timezone`, a `datetime.timezone` or a string which will be passed to `pytz.timezone`,
tz: either a `ZoneInfo`, a `datetime.timezone` or a string which will be passed to `ZoneInfo`,
(or `pytz.timezone` on 3.8) to get a timezone,
if provided now will be converted to this timezone.
```py title="IsNow"
Expand All @@ -161,9 +185,7 @@ def __init__(
```
"""
if isinstance(tz, str):
import pytz

tz = pytz.timezone(tz)
tz = _zoneinfo(tz)

self.tz = tz

Expand All @@ -184,12 +206,7 @@ def _get_now(self) -> datetime:
if self.tz is None:
return datetime.now()
else:
try:
from datetime import UTC

utc_now = datetime.now(UTC).replace(tzinfo=timezone.utc)
except ImportError:
utc_now = datetime.utcnow().replace(tzinfo=timezone.utc)
utc_now = datetime.now(tz=timezone.utc).replace(tzinfo=timezone.utc)
return utc_now.astimezone(self.tz)

def prepare(self, other: Any) -> datetime:
Expand All @@ -210,14 +227,14 @@ class IsDate(IsNumeric[date]):
def __init__(
self,
*,
approx: Optional[date] = None,
delta: Optional[Union[timedelta, int, float]] = None,
gt: Optional[date] = None,
lt: Optional[date] = None,
ge: Optional[date] = None,
le: Optional[date] = None,
approx: date | None = None,
delta: timedelta | int | float | None = None,
gt: date | None = None,
lt: date | None = None,
ge: date | None = None,
le: date | None = None,
iso_string: bool = False,
format_string: Optional[str] = None,
format_string: str | None = None,
):
"""
Args:
Expand Down Expand Up @@ -286,7 +303,7 @@ def __init__(
self,
*,
iso_string: bool = False,
format_string: Optional[str] = None,
format_string: str | None = None,
):
"""
Args:
Expand Down
2 changes: 1 addition & 1 deletion dirty_equals/_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def equals(self, other: Any) -> bool:
T = TypeVar('T')


@lru_cache()
@lru_cache
def _build_type_adapter(ta: type[TypeAdapter[T]], schema: T) -> TypeAdapter[T]:
return ta(schema)

Expand Down
18 changes: 18 additions & 0 deletions docs/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def remove_files(files: Files) -> Files:


def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str:
markdown = remove_code_fence_attributes(markdown)
return add_version(markdown, page)


Expand All @@ -56,3 +57,20 @@ def add_version(markdown: str, page: Page) -> str:
version_str = 'Documentation for development version'
markdown = re.sub(r'{{ *version *}}', version_str, markdown)
return markdown


def remove_code_fence_attributes(markdown: str) -> str:
"""
There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use
`py key="value"` to provide attributes to pytest-examples, then remove those attributes here.
https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/
"""

def remove_attrs(match: re.Match[str]) -> str:
suffix = re.sub(
r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M
)
return f'{match.group(1)}{suffix}'

return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M)
13 changes: 6 additions & 7 deletions docs/types/datetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,17 @@ based on the `enforce_tz` parameter:

Example

```py title="IsDatetime & timezones"
```py title="IsDatetime & timezones" requires="3.9"
from datetime import datetime

import pytz
from zoneinfo import ZoneInfo

from dirty_equals import IsDatetime

tz_london = pytz.timezone('Europe/London')
new_year_london = tz_london.localize(datetime(2000, 1, 1))
tz_london = ZoneInfo('Europe/London')
new_year_london = datetime(2000, 1, 1, tzinfo=tz_london)

tz_nyc = pytz.timezone('America/New_York')
new_year_eve_nyc = tz_nyc.localize(datetime(1999, 12, 31, 19, 0, 0))
tz_nyc = ZoneInfo('America/New_York')
new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=tz_nyc)

assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False)
assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True)
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ classifiers = [
]
requires-python = '>=3.8'
dependencies = [
'pytz>=2021.3',
'pytz>=2021.3;python_version<"3.9"',
]
optional-dependencies = {pydantic = ['pydantic>=2.4.2'] }
dynamic = ['version']
Expand All @@ -55,7 +55,10 @@ flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'}
mccabe = { max-complexity = 14 }
isort = { known-first-party = ['tests'] }
format.quote-style = 'single'
target-version = 'py37'
target-version = 'py38'

[tool.ruff.pydocstyle]
convention = 'google'

[tool.pytest.ini_options]
testpaths = "tests"
Expand Down
2 changes: 0 additions & 2 deletions requirements/pyproject.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ pydantic-core==2.10.1
# via
# -c requirements/linting.txt
# pydantic
pytz==2023.3.post1
# via dirty-equals (pyproject.toml)
typing-extensions==4.8.0
# via
# -c requirements/linting.txt
Expand Down
1 change: 1 addition & 0 deletions requirements/tests.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pytest
pytest-mock
pytest-pretty
pytest-examples
pytz
2 changes: 2 additions & 0 deletions requirements/tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pytest-mock==3.12.0
# via -r requirements/tests.in
pytest-pretty==1.2.0
# via -r requirements/tests.in
pytz==2023.3.post1
# via -r requirements/tests.in
rich==13.6.0
# via pytest-pretty
ruff==0.1.5
Expand Down
28 changes: 19 additions & 9 deletions tests/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

from dirty_equals import IsDate, IsDatetime, IsNow, IsToday

try:
from zoneinfo import ZoneInfo
except ImportError:
ZoneInfo = None


@pytest.mark.parametrize(
'value,dirty,expect_match',
Expand Down Expand Up @@ -82,6 +87,14 @@ def test_is_datetime(value, dirty, expect_match):
assert value != dirty


@pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo')
def test_is_datetime_zoneinfo():
london = datetime(2022, 2, 15, 15, 15, tzinfo=ZoneInfo('Europe/London'))
ny = datetime(2022, 2, 15, 10, 15, tzinfo=ZoneInfo('America/New_York'))
assert london != IsDatetime(approx=ny)
assert london == IsDatetime(approx=ny, enforce_tz=False)


def test_is_now_dt():
is_now = IsNow()
dt = datetime.now()
Expand All @@ -98,14 +111,10 @@ def test_repr():
assert str(v) == 'IsDatetime(approx=datetime.datetime(2032, 1, 2, 3, 4, 5), iso_string=True)'


@pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo')
def test_is_now_tz():
try:
from datetime import UTC

utc_now = datetime.now(UTC).replace(tzinfo=timezone.utc)
except ImportError:
utc_now = datetime.utcnow().replace(tzinfo=timezone.utc)
now_ny = utc_now.astimezone(pytz.timezone('America/New_York'))
utc_now = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
now_ny = utc_now.astimezone(ZoneInfo('America/New_York'))
assert now_ny == IsNow(tz='America/New_York')
# depends on the time of year and DST
assert now_ny == IsNow(tz=timezone(timedelta(hours=-5))) | IsNow(tz=timezone(timedelta(hours=-4)))
Expand All @@ -132,10 +141,11 @@ def test_is_now_relative(monkeypatch):
assert IsNow() == datetime(2020, 1, 1, 12, 13, 14)


@pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo')
def test_tz():
new_year_london = pytz.timezone('Europe/London').localize(datetime(2000, 1, 1))
new_year_london = datetime(2000, 1, 1, tzinfo=ZoneInfo('Europe/London'))

new_year_eve_nyc = pytz.timezone('America/New_York').localize(datetime(1999, 12, 31, 19, 0, 0))
new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=ZoneInfo('America/New_York'))

assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False)
assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True)
Expand Down
6 changes: 6 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ def test_docstrings(example: CodeExample, eval_example: EvalExample):
# I001 refers is a problem with black and ruff disagreeing about blank lines :shrug:
eval_example.set_config(ruff_ignore=['E711', 'E712', 'I001'])

requires = prefix_settings.get('requires')
if requires:
requires_version = tuple(int(v) for v in requires.split('.'))
if sys.version_info < requires_version:
pytest.skip(f'requires python {requires}')

if prefix_settings.get('test') != 'skip':
if eval_example.update_examples:
eval_example.run_print_update(example)
Expand Down

0 comments on commit cb2b800

Please sign in to comment.