From cb2b8000ff3a3f6b2647f1e9b86d0000f3c111db Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 15 Nov 2023 05:33:06 +0000 Subject: [PATCH] use zoneinfo instead of pytz where possible (#93) --- dirty_equals/_datetime.py | 75 +++++++++++++++++++++++--------------- dirty_equals/_other.py | 2 +- docs/plugins.py | 18 +++++++++ docs/types/datetime.md | 13 +++---- pyproject.toml | 7 +++- requirements/pyproject.txt | 2 - requirements/tests.in | 1 + requirements/tests.txt | 2 + tests/test_datetime.py | 28 +++++++++----- tests/test_docs.py | 6 +++ 10 files changed, 104 insertions(+), 50 deletions(-) diff --git a/dirty_equals/_datetime.py b/dirty_equals/_datetime.py index b4d6f8f..6a98ecb 100644 --- a/dirty_equals/_datetime.py +++ b/dirty_equals/_datetime.py @@ -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]): """ @@ -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, ): """ @@ -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())`, @@ -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: @@ -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" @@ -161,9 +185,7 @@ def __init__( ``` """ if isinstance(tz, str): - import pytz - - tz = pytz.timezone(tz) + tz = _zoneinfo(tz) self.tz = tz @@ -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: @@ -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: @@ -286,7 +303,7 @@ def __init__( self, *, iso_string: bool = False, - format_string: Optional[str] = None, + format_string: str | None = None, ): """ Args: diff --git a/dirty_equals/_other.py b/dirty_equals/_other.py index f61c12c..6d462ff 100644 --- a/dirty_equals/_other.py +++ b/dirty_equals/_other.py @@ -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) diff --git a/docs/plugins.py b/docs/plugins.py index b1f8e65..cdf2729 100644 --- a/docs/plugins.py +++ b/docs/plugins.py @@ -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) @@ -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) diff --git a/docs/types/datetime.md b/docs/types/datetime.md index e9ac4fb..cb60b72 100644 --- a/docs/types/datetime.md +++ b/docs/types/datetime.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 07db77f..86d9744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'] @@ -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" diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index f8be50d..6b295eb 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -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 diff --git a/requirements/tests.in b/requirements/tests.in index 0416027..71e9143 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -4,3 +4,4 @@ pytest pytest-mock pytest-pretty pytest-examples +pytz diff --git a/requirements/tests.txt b/requirements/tests.txt index 77154df..eac84d6 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -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 diff --git a/tests/test_datetime.py b/tests/test_datetime.py index ad1b21a..01990cd 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -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', @@ -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() @@ -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))) @@ -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) diff --git a/tests/test_docs.py b/tests/test_docs.py index 4351ea6..501a2b7 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -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)