diff --git a/alembic/script/base.py b/alembic/script/base.py index d0f9abbd..5766d838 100644 --- a/alembic/script/base.py +++ b/alembic/script/base.py @@ -23,6 +23,7 @@ from . import write_hooks from .. import util from ..runtime import migration +from ..util import compat from ..util import not_none if TYPE_CHECKING: @@ -35,9 +36,14 @@ from ..runtime.migration import StampStep try: - from dateutil import tz + if compat.py39: + from zoneinfo import ZoneInfo + from zoneinfo import ZoneInfoNotFoundError + else: + from backports.zoneinfo import ZoneInfo # type: ignore[import-not-found,no-redef] # noqa: E501 + from backports.zoneinfo import ZoneInfoNotFoundError # type: ignore[import-not-found,no-redef] # noqa: E501 except ImportError: - tz = None # type: ignore[assignment] + ZoneInfo = None # type: ignore[assignment, misc] _sourceless_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)(c|o)?$") _only_source_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)$") @@ -604,23 +610,26 @@ def _ensure_directory(self, path: str) -> None: def _generate_create_date(self) -> datetime.datetime: if self.timezone is not None: - if tz is None: + if ZoneInfo is None: raise util.CommandError( - "The library 'python-dateutil' is required " - "for timezone support" + "Python >= 3.9 is required for timezone support or" + "the 'backports.zoneinfo' package must be installed." ) # First, assume correct capitalization - tzinfo = tz.gettz(self.timezone) - if tzinfo is None: - # Fall back to uppercase - tzinfo = tz.gettz(self.timezone.upper()) + try: + tzinfo = ZoneInfo(self.timezone) + except ZoneInfoNotFoundError: + tzinfo = None if tzinfo is None: - raise util.CommandError( - "Can't locate timezone: %s" % self.timezone - ) + try: + tzinfo = ZoneInfo(self.timezone.upper()) + except ZoneInfoNotFoundError: + raise util.CommandError( + "Can't locate timezone: %s" % self.timezone + ) from None create_date = ( datetime.datetime.utcnow() - .replace(tzinfo=tz.tzutc()) + .replace(tzinfo=datetime.timezone.utc) .astimezone(tzinfo) ) else: diff --git a/alembic/templates/async/alembic.ini.mako b/alembic/templates/async/alembic.ini.mako index bc9f2d50..c9a06d44 100644 --- a/alembic/templates/async/alembic.ini.mako +++ b/alembic/templates/async/alembic.ini.mako @@ -14,9 +14,9 @@ prepend_sys_path = . # timezone to use when rendering the date within the migration file # as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# string value is passed to dateutil.tz.gettz() +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any requied deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() # leave blank for localtime # timezone = diff --git a/alembic/templates/generic/alembic.ini.mako b/alembic/templates/generic/alembic.ini.mako index c18ddb4e..7dfaae5a 100644 --- a/alembic/templates/generic/alembic.ini.mako +++ b/alembic/templates/generic/alembic.ini.mako @@ -16,9 +16,9 @@ prepend_sys_path = . # timezone to use when rendering the date within the migration file # as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# string value is passed to dateutil.tz.gettz() +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any requied deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() # leave blank for localtime # timezone = diff --git a/alembic/templates/multidb/alembic.ini.mako b/alembic/templates/multidb/alembic.ini.mako index a9ea0755..f300bc8a 100644 --- a/alembic/templates/multidb/alembic.ini.mako +++ b/alembic/templates/multidb/alembic.ini.mako @@ -16,9 +16,9 @@ prepend_sys_path = . # timezone to use when rendering the date within the migration file # as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# string value is passed to dateutil.tz.gettz() +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any requied deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() # leave blank for localtime # timezone = diff --git a/docs/build/requirements.txt b/docs/build/requirements.txt index d042a565..bf162865 100644 --- a/docs/build/requirements.txt +++ b/docs/build/requirements.txt @@ -1,7 +1,7 @@ git+https://github.com/sqlalchemyorg/changelog.git#egg=changelog git+https://github.com/sqlalchemyorg/sphinx-paramlinks.git#egg=sphinx-paramlinks git+https://github.com/sqlalchemy/sqlalchemy.git -python-dateutil +backports.zoneinfo;python_version<"3.9" # because there's a dependency in pyfiles.py Mako importlib-metadata;python_version<"3.9" diff --git a/docs/build/tutorial.rst b/docs/build/tutorial.rst index 6e9552c5..1a66528a 100644 --- a/docs/build/tutorial.rst +++ b/docs/build/tutorial.rst @@ -141,9 +141,9 @@ The file generated with the "generic" configuration looks like:: # timezone to use when rendering the date within the migration file # as well as the filename. - # If specified, requires the python-dateutil library that can be - # installed by adding `alembic[tz]` to the pip requirements - # string value is passed to dateutil.tz.gettz() + # If specified, requires the python>=3.9 or backports.zoneinfo library. + # Any requied deps can installed by adding `alembic[tz]` to the pip requirements + # string value is passed to ZoneInfo() # leave blank for localtime # timezone = @@ -297,16 +297,18 @@ This file contains the following features: * ``timezone`` - an optional timezone name (e.g. ``UTC``, ``EST5EDT``, etc.) that will be applied to the timestamp which renders inside the migration - file's comment as well as within the filename. This option requires installing - the ``python-dateutil`` library. If ``timezone`` is specified, + file's comment as well as within the filename. This option requires Python>=3.9 + or installing the ``backports.zoneinfo`` library. If ``timezone`` is specified, the create date object is no longer derived from ``datetime.datetime.now()`` and is instead generated as:: datetime.datetime.utcnow().replace( - tzinfo=dateutil.tz.tzutc() - ).astimezone( - dateutil.tz.gettz() - ) + tzinfo=datetime.timezone.utc + ).astimezone(ZoneInfo()) + + .. versionchanged:: 1.13.0 Python standard library ``zoneinfo`` is now used + for timezone rendering in migrations; previously ``python-dateutil`` + was used. * ``truncate_slug_length`` - defaults to 40, the max number of characters to include in the "slug" field. diff --git a/docs/build/unreleased/1339.rst b/docs/build/unreleased/1339.rst new file mode 100644 index 00000000..9f4560c1 --- /dev/null +++ b/docs/build/unreleased/1339.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: usecase + :tickets: 1339 + + Replaced ``python-dateutil`` with the standard library module + `zoneinfo `. + This module was added in Python 3.9, so previous version will been + to install the backport of it, available by installing the ``backports.zoneinfo`` + library. The ``alembic[tz]`` option has been updated accordingly. diff --git a/setup.cfg b/setup.cfg index 7453183c..6654c462 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ install_requires = [options.extras_require] tz = - python-dateutil + backports.zoneinfo;python_version<"3.9" [options.package_data] alembic = *.pyi, py.typed diff --git a/tests/test_script_production.py b/tests/test_script_production.py index ccc1a107..3b5a6f60 100644 --- a/tests/test_script_production.py +++ b/tests/test_script_production.py @@ -4,7 +4,6 @@ import re from unittest.mock import patch -from dateutil import tz import sqlalchemy as sa from sqlalchemy import Column from sqlalchemy import inspect @@ -41,6 +40,11 @@ from alembic.testing.fixtures import TestBase from alembic.util import CommandError +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo + env, abc, def_ = None, None, None @@ -189,12 +193,16 @@ def test_args(self): @testing.combinations( ( - datetime.datetime(2012, 7, 25, 15, 8, 5, tzinfo=tz.gettz("UTC")), + datetime.datetime( + 2012, 7, 25, 15, 8, 5, tzinfo=datetime.timezone.utc + ), "%s/versions/1343228885_12345_this_is_a_" "message_2012_7_25_15_8_5.py", ), ( - datetime.datetime(2012, 7, 25, 15, 8, 6, tzinfo=tz.gettz("UTC")), + datetime.datetime( + 2012, 7, 25, 15, 8, 6, tzinfo=datetime.timezone.utc + ), "%s/versions/1343228886_12345_this_is_a_" "message_2012_7_25_15_8_6.py", ), @@ -227,7 +235,8 @@ def _test_tz(self, timezone_arg, given, expected): with mock.patch( "alembic.script.base.datetime", mock.Mock( - datetime=mock.Mock(utcnow=lambda: given, now=lambda: given) + datetime=mock.Mock(utcnow=lambda: given, now=lambda: given), + timezone=datetime.timezone, ), ): create_date = script._generate_create_date() @@ -238,7 +247,7 @@ def test_custom_tz(self): "EST5EDT", datetime.datetime(2012, 7, 25, 15, 8, 5), datetime.datetime( - 2012, 7, 25, 11, 8, 5, tzinfo=tz.gettz("EST5EDT") + 2012, 7, 25, 11, 8, 5, tzinfo=ZoneInfo("EST5EDT") ), ) @@ -247,7 +256,7 @@ def test_custom_tz_lowercase(self): "est5edt", datetime.datetime(2012, 7, 25, 15, 8, 5), datetime.datetime( - 2012, 7, 25, 11, 8, 5, tzinfo=tz.gettz("EST5EDT") + 2012, 7, 25, 11, 8, 5, tzinfo=ZoneInfo("EST5EDT") ), ) @@ -255,7 +264,7 @@ def test_custom_tz_utc(self): self._test_tz( "utc", datetime.datetime(2012, 7, 25, 15, 8, 5), - datetime.datetime(2012, 7, 25, 15, 8, 5, tzinfo=tz.gettz("UTC")), + datetime.datetime(2012, 7, 25, 15, 8, 5, tzinfo=ZoneInfo("UTC")), ) def test_custom_tzdata_tz(self): @@ -263,7 +272,7 @@ def test_custom_tzdata_tz(self): "Europe/Berlin", datetime.datetime(2012, 7, 25, 15, 8, 5), datetime.datetime( - 2012, 7, 25, 17, 8, 5, tzinfo=tz.gettz("Europe/Berlin") + 2012, 7, 25, 17, 8, 5, tzinfo=ZoneInfo("Europe/Berlin") ), ) @@ -284,10 +293,12 @@ def test_tz_cant_locate(self): datetime.datetime(2012, 7, 25, 15, 8, 5), ) - def test_no_dateutil_module(self): - with patch("alembic.script.base.tz", new=None): + def test_no_zoneinfo_module(self): + with patch("alembic.script.base.ZoneInfo", new=None): with expect_raises_message( - CommandError, "The library 'python-dateutil' is required" + CommandError, + "Python >= 3.9 is required for timezone support or" + "the 'backports.zoneinfo' package must be installed.", ): self._test_tz( "utc", diff --git a/tox.ini b/tox.ini index 24219bb4..ef6a8ddd 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ deps=pytest>4.6 cov: pytest-cov sqlalchemy: sqlalchemy>=1.3.0 mako - python-dateutil + backports.zoneinfo;python_version<"3.9" zimports black==23.3.0 greenlet>=1 @@ -74,7 +74,6 @@ deps= sqlalchemy>=2 mako types-pkg-resources - types-python-dateutil # is imported in alembic/testing and mypy complains if it's not installed. pytest commands = mypy ./alembic/ --exclude alembic/templates