diff --git a/docs/conf.py b/docs/conf.py index eb61e17..81229b2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] intersphinx_mapping = { "alembic": ("https://alembic.sqlalchemy.org/en/latest/", None), + "dateutil": ("https://dateutil.readthedocs.io/en/stable/", None), "humanize": ("https://humanize.readthedocs.io/en/stable/", None), "mako": ("https://docs.makotemplates.org/en/latest/", None), "packaging": ("https://packaging.python.org/en/latest/", None), diff --git a/pyproject.toml b/pyproject.toml index fec7d85..52a5f23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "bcrypt", + 'python-dateutil; python_version < "3.9"', "humanize", 'importlib-metadata; python_version < "3.10"', "importlib_resources ; python_version < '3.9'", @@ -42,7 +43,7 @@ dependencies = [ [project.optional-dependencies] db = ["SQLAlchemy", "alembic", "alembic-postgresql-enum"] docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] -tests = ["pylint", "pytest", "pytest-cov", "tox"] +tests = ["pylint", "pytest", "pytest-cov", "tox", "python-dateutil"] [project.scripts] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 69e6218..418fe28 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -25,6 +25,7 @@ WuttJamaican - app handler """ # pylint: disable=too-many-lines +import datetime import logging import os import sys @@ -35,6 +36,7 @@ from importlib.metadata import version import humanize from wuttjamaican.util import ( + get_timezone_by_name, localtime, load_entry_points, load_object, @@ -110,6 +112,7 @@ class AppHandler: # pylint: disable=too-many-public-methods def __init__(self, config): self.config = config self.handlers = {} + self.timezones = {} @property def appname(self): @@ -528,19 +531,113 @@ class AppHandler: # pylint: disable=too-many-public-methods """ return make_full_name(*parts) - def localtime(self, dt=None, tzinfo=True): + def get_timezone(self, key="default"): """ - This returns a datetime in the system-local timezone. It is a - convenience wrapper around - :func:`~wuttjamaican.util.localtime()`. + Get the configured (or system default) timezone object. + + This checks config for a setting which corresponds to the + given ``key``, then calls + :func:`~wuttjamaican.util.get_timezone_by_name()` to get the + actual timezone object. + + The default key corresponds to the true "local" timezone, but + other keys may correspond to other configured timezones (if + applicable). + + As a special case for the default key only: If no config value + is found, Python itself will determine the default system + local timezone. + + For any non-default key, an error is raised if no config value + is found. + + .. note:: + + The app handler *caches* all timezone objects, to avoid + unwanted repetitive lookups when processing multiple + datetimes etc. (Since this method is called by + :meth:`localtime()`.) Therefore whenever timezone config + values are changed, an app restart will be necessary. + + Example config: + + .. code-block:: ini + + [wutta] + timezone.default = America/Chicago + timezone.westcoast = America/Los_Angeles + + Example usage:: + + tz_default = app.get_timezone() + tz_westcoast = app.get_timezone("westcoast") + + See also :meth:`get_timezone_name()`. + + :param key: Config key for desired timezone. + + :returns: :class:`python:datetime.tzinfo` instance """ - return localtime(dt=dt, tzinfo=tzinfo) + if key not in self.timezones: + setting = f"{self.appname}.timezone.{key}" + tzname = self.config.get(setting) + if tzname: + self.timezones[key] = get_timezone_by_name(tzname) + + elif key == "default": + # fallback to system default + self.timezones[key] = datetime.datetime.now().astimezone().tzinfo + + else: + # alternate key was specified, but no config found, so check + # again with require() to force error + self.timezones[key] = self.config.require(setting) + + return self.timezones[key] + + def get_timezone_name(self, key="default"): + """ + Get the display name for the configured (or system default) + timezone. + + This calls :meth:`get_timezone()` and then uses some + heuristics to determine the name. + + :param key: Config key for desired timezone. + + :returns: String name for the timezone. + """ + tz = self.get_timezone(key=key) + try: + # TODO: this should work for zoneinfo.ZoneInfo objects, + # but not sure yet about dateutils.tz ? + return tz.key + except AttributeError: + # this should work for system default fallback, afaik + dt = datetime.datetime.now(tz) + return dt.tzname() + + def localtime(self, dt=None, local_zone=None, **kw): + """ + This produces a datetime in the "local" timezone. + + This is a convenience wrapper around + :func:`~wuttjamaican.util.localtime()`; however it also calls + :meth:`get_timezone()` to override the ``local_zone`` param + (unless caller specifies that). + + See also :meth:`make_utc()` which is sort of the inverse. + """ + kw["local_zone"] = local_zone or self.get_timezone() + return localtime(dt=dt, **kw) def make_utc(self, dt=None, tzinfo=False): """ This returns a datetime local to the UTC timezone. It is a convenience wrapper around :func:`~wuttjamaican.util.make_utc()`. + + See also :meth:`localtime()` which is sort of the inverse. """ return make_utc(dt=dt, tzinfo=tzinfo) @@ -795,7 +892,7 @@ class AppHandler: # pylint: disable=too-many-public-methods return "" return value.strftime(self.display_format_date) - def render_datetime(self, value): + def render_datetime(self, value, local=True): """ Return a human-friendly display string for the given datetime. @@ -804,14 +901,16 @@ class AppHandler: # pylint: disable=too-many-public-methods :param value: A :class:`python:datetime.datetime` instance (or ``None``). - :returns: Display string. + :param local: By default the ``value`` will first be passed to + :meth:`localtime()` to normalize it for display. Specify + ``local=False`` to skip that and render the value as-is. + + :returns: Rendered datetime as string. """ if value is None: return "" - - if not value.tzinfo: + if local: value = self.localtime(value) - return value.strftime(self.display_format_datetime) def render_error(self, error): diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 2f8e509..4a4d41f 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -190,10 +190,40 @@ def make_full_name(*parts): return " ".join(parts) -def localtime(dt=None, tzinfo=True): +def get_timezone_by_name(tzname): """ - This returns a datetime in the system-local timezone. By default - it will be *zone-aware*. + Retrieve a timezone object by name. + + This is mostly a compatibility wrapper, since older Python is + missing the :mod:`python:zoneinfo` module. + + For Python 3.9 and newer, this instantiates + :class:`python:zoneinfo.ZoneInfo`. + + For Python 3.8, this calls :func:`dateutil:dateutil.tz.gettz()`. + + See also :meth:`~wuttjamaican.app.AppHandler.get_timezone()` on + the app handler. + + :param tzname: String name for timezone. + + :returns: :class:`python:datetime.tzinfo` instance + """ + try: + from zoneinfo import ZoneInfo # pylint: disable=import-outside-toplevel + + return ZoneInfo(tzname) + + except ImportError: # python 3.8 + from dateutil.tz import gettz # pylint: disable=import-outside-toplevel + + return gettz(tzname) + + +def localtime(dt=None, from_utc=True, want_tzinfo=True, local_zone=None): + """ + This produces a datetime in the "local" timezone. By default it + will be *zone-aware*. See also the shortcut :meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app @@ -204,42 +234,49 @@ def localtime(dt=None, tzinfo=True): :param dt: Optional :class:`python:datetime.datetime` instance. If not specified, the current time will be used. - :param tzinfo: Boolean indicating whether the return value should - have its :attr:`~python:datetime.datetime.tzinfo` attribute - set. This is true by default in which case the return value - will be zone-aware. + :param from_utc: Boolean indicating whether a naive ``dt`` is + already (effectively) in UTC timezone. Set this to false when + providing a naive ``dt`` which is already in "local" timezone + instead of UTC. This flag is ignored if ``dt`` is zone-aware. - :returns: :class:`python:datetime.datetime` instance in - system-local timezone. + :param want_tzinfo: Boolean indicating whether the resulting + datetime should have its + :attr:`~python:datetime.datetime.tzinfo` attribute set. Set + this to false if you want a naive value; it's true by default, + for zone-aware. + + :param local_zone: Optional :class:`python:datetime.tzinfo` + instance to use as "local" timezone, instead of relying on + Python to determine the system local timezone. + + :returns: :class:`python:datetime.datetime` instance in local + timezone. """ - # thanks to this stackoverflow post for the timezone logic, - # since as of now we don't have that anywhere in config. - # https://stackoverflow.com/a/39079819 - # https://docs.python.org/3/library/datetime.html#datetime.datetime.astimezone - # use current time if none provided if dt is None: dt = datetime.datetime.now(datetime.timezone.utc) - dt = dt.astimezone() - if tzinfo: - return dt - return dt.replace(tzinfo=None) - # otherwise may need to convert timezone - if dt.tzinfo: - dt = dt.astimezone() - if tzinfo: - return dt - return dt.replace(tzinfo=None) + # set dt's timezone if needed + if not dt.tzinfo: + # UTC is default assumption unless caller says otherwise + if from_utc: + dt = dt.replace(tzinfo=datetime.timezone.utc) + elif local_zone: + dt = dt.replace(tzinfo=local_zone) + else: # default system local timezone + tz = dt.astimezone().tzinfo + dt = dt.replace(tzinfo=tz) - # naive value returned as-is.. - if not tzinfo: + # convert to local timezone + if local_zone: + dt = dt.astimezone(local_zone) + else: + dt = dt.astimezone() + + # maybe strip tzinfo + if want_tzinfo: return dt - - # ..unless tzinfo is wanted, in which case this assumes naive - # value is in the UTC timezone - dt = dt.replace(tzinfo=datetime.timezone.utc) - return dt.astimezone() + return dt.replace(tzinfo=None) def make_utc(dt=None, tzinfo=False): diff --git a/tests/test_app.py b/tests/test_app.py index 8bafb2c..b6f4c7a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,9 +16,10 @@ from mako.template import Template import wuttjamaican.enum from wuttjamaican import app as mod +from wuttjamaican.exc import ConfigurationError from wuttjamaican.progress import ProgressBase from wuttjamaican.conf import WuttaConfig -from wuttjamaican.util import UNSPECIFIED +from wuttjamaican.util import UNSPECIFIED, get_timezone_by_name from wuttjamaican.testing import FileTestCase, ConfigTestCase from wuttjamaican.batch import BatchHandler @@ -426,6 +427,80 @@ app_title = WuttaTest name = self.app.make_full_name("Fred", "", "Flintstone", "") self.assertEqual(name, "Fred Flintstone") + def test_get_timezone(self): + # cache is empty at first + self.assertEqual(self.app.timezones, {}) + + # fetch default system local timezone + # nb. actual value depends on machine where tests run + system = self.app.get_timezone() + self.assertIsInstance(system, datetime.tzinfo) + # cache no longer empty + self.assertEqual(len(self.app.timezones), 1) + self.assertIn("default", self.app.timezones) + self.assertIs(self.app.timezones["default"], system) + + # fetch configured default + self.app.timezones.clear() # clear cache + self.config.setdefault("wuttatest.timezone.default", "Africa/Addis_Ababa") + default = self.app.get_timezone() + self.assertIsInstance(default, datetime.tzinfo) + dt = datetime.datetime(2025, 12, 16, 22, 0, tzinfo=default) + self.assertEqual(default.utcoffset(dt), datetime.timedelta(hours=3)) + # cache no longer empty + self.assertEqual(len(self.app.timezones), 1) + self.assertIn("default", self.app.timezones) + self.assertIs(self.app.timezones["default"], default) + # fetching again gives cached instance + self.assertIs(self.app.get_timezone(), default) + + # fetch configured alternate + self.config.setdefault("wuttatest.timezone.foo", "America/New_York") + foo = self.app.get_timezone("foo") + self.assertIsInstance(foo, datetime.tzinfo) + self.assertIn("foo", self.app.timezones) + self.assertIs(self.app.timezones["foo"], foo) + + # error if alternate not configured + self.assertRaises(ConfigurationError, self.app.get_timezone, "bar") + self.assertNotIn("bar", self.app.timezones) + + def test_get_timezone_name(self): + # cache is empty at first + self.assertEqual(self.app.timezones, {}) + + # fetch default system local timezone + # nb. actual value depends on machine where tests run + system = self.app.get_timezone_name() + self.assertIsInstance(system, str) + self.assertLess(0, len(system)) + # cache no longer empty + self.assertEqual(len(self.app.timezones), 1) + self.assertIn("default", self.app.timezones) + + # fetch configured default + self.app.timezones.clear() # clear cache + self.config.setdefault("wuttatest.timezone.default", "Africa/Addis_Ababa") + default = self.app.get_timezone_name() + # nb. this check won't work for python 3.8 + if sys.version_info >= (3, 9): + self.assertEqual(default, "Africa/Addis_Ababa") + # cache no longer empty + self.assertEqual(len(self.app.timezones), 1) + self.assertIn("default", self.app.timezones) + + # fetch configured alternate + self.config.setdefault("wuttatest.timezone.foo", "America/New_York") + foo = self.app.get_timezone_name("foo") + # nb. this check won't work for python 3.8 + if sys.version_info >= (3, 9): + self.assertEqual(foo, "America/New_York") + self.assertIn("foo", self.app.timezones) + + # error if alternate not configured + self.assertRaises(ConfigurationError, self.app.get_timezone_name, "bar") + self.assertNotIn("bar", self.app.timezones) + def test_localtime(self): dt = self.app.localtime() self.assertIsInstance(dt, datetime.datetime) @@ -516,15 +591,38 @@ app_title = WuttaTest self.assertEqual(self.app.render_date(dt), "2024-12-11") def test_render_datetime(self): - self.assertEqual(self.app.render_datetime(None), "") + tzlocal = get_timezone_by_name("America/Los_Angeles") + with patch.object(self.app, "get_timezone", return_value=tzlocal): - dt = datetime.datetime(2024, 12, 11, 8, 30, tzinfo=datetime.timezone.utc) - self.assertEqual(self.app.render_datetime(dt), "2024-12-11 08:30+0000") + # null value + self.assertEqual(self.app.render_datetime(None), "") - dt = datetime.datetime(2024, 12, 11, 8, 30) - text = self.app.render_datetime(dt) - # TODO: should override local timezone for more complete test - self.assertTrue(text.startswith("2024-12-")) + # naive UTC + dt = datetime.datetime(2024, 12, 17, 1, 12) + self.assertEqual( + self.app.render_datetime(dt, local=True), "2024-12-16 17:12-0800" + ) + self.assertEqual( + self.app.render_datetime(dt, local=False), "2024-12-17 01:12" + ) + + # aware UTC + dt = datetime.datetime(2024, 12, 17, 1, 12, tzinfo=datetime.timezone.utc) + self.assertEqual( + self.app.render_datetime(dt, local=True), "2024-12-16 17:12-0800" + ) + self.assertEqual( + self.app.render_datetime(dt, local=False), "2024-12-17 01:12+0000" + ) + + # aware local + dt = datetime.datetime(2024, 12, 16, 19, 12, tzinfo=tzlocal) + self.assertEqual( + self.app.render_datetime(dt, local=True), "2024-12-16 19:12-0800" + ) + self.assertEqual( + self.app.render_datetime(dt, local=False), "2024-12-16 19:12-0800" + ) def test_render_error(self): diff --git a/tests/test_util.py b/tests/test_util.py index a73b681..398a23e 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -165,49 +165,122 @@ class TestLoadObject(TestCase): self.assertIs(result, TestCase) +class TestGetTimezoneByName(TestCase): + + def test_modern(self): + try: + import zoneinfo + except ImportError: + self.assertLess(sys.version_info, (3, 9)) + pytest.skip("this test is not relevant before python 3.9") + + tz = mod.get_timezone_by_name("America/Chicago") + self.assertIsInstance(tz, zoneinfo.ZoneInfo) + self.assertIsInstance(tz, datetime.tzinfo) + self.assertEqual(tz.key, "America/Chicago") + + def test_legacy(self): + import dateutil.tz + + orig_import = __import__ + + def mock_import(name, *args, **kwargs): + if name == "zoneinfo": + raise ImportError + return orig_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=mock_import): + tz = mod.get_timezone_by_name("America/Chicago") + self.assertIsInstance(tz, dateutil.tz.tzfile) + self.assertIsInstance(tz, datetime.tzinfo) + dt = datetime.datetime.now(tz) + self.assertIn(dt.tzname(), ["CDT", "CST"]) + + class TestLocaltime(TestCase): + def test_naive_utc(self): + # nb. must override local_zone for test consistency + tz = datetime.timezone(-datetime.timedelta(hours=5)) + dt = datetime.datetime(2025, 12, 16, 0, 16) # utc + result = mod.localtime(dt, local_zone=tz) + self.assertIsInstance(result, datetime.datetime) + self.assertIs(result.tzinfo, tz) + self.assertEqual(result, datetime.datetime(2025, 12, 15, 19, 16, tzinfo=tz)) + + def test_naive_local(self): + # nb. must override local_zone for test consistency + tz = datetime.timezone(-datetime.timedelta(hours=5)) + dt = datetime.datetime(2025, 12, 15, 19, 16) # local + + # can test precisely when overriding local_zone + result = mod.localtime(dt, local_zone=tz, from_utc=False) + self.assertIsInstance(result, datetime.datetime) + self.assertIs(result.tzinfo, tz) + self.assertEqual(result, datetime.datetime(2025, 12, 15, 19, 16, tzinfo=tz)) + + # must test loosely for fallback to system local timezone + result = mod.localtime(dt, from_utc=False) + self.assertIsInstance(result, datetime.datetime) + self.assertIsInstance(result.tzinfo, datetime.tzinfo) + self.assertEqual(result.year, 2025) + self.assertEqual(result.month, 12) + + def test_aware_utc(self): + # nb. must override local_zone for test consistency + tz = datetime.timezone(-datetime.timedelta(hours=5)) + dt = datetime.datetime(2025, 12, 16, 0, 16, tzinfo=datetime.timezone.utc) + result = mod.localtime(dt, local_zone=tz) + self.assertIsInstance(result, datetime.datetime) + self.assertIs(result.tzinfo, tz) + self.assertEqual(result, datetime.datetime(2025, 12, 15, 19, 16, tzinfo=tz)) + + def test_aware_local(self): + # nb. must override local_zone for test consistency + tz = datetime.timezone(-datetime.timedelta(hours=5)) + other = datetime.timezone(-datetime.timedelta(hours=7)) + dt = datetime.datetime(2025, 12, 15, 17, 16, tzinfo=other) + + # can test precisely when overriding local_zone + result = mod.localtime(dt, local_zone=tz) + self.assertIsInstance(result, datetime.datetime) + self.assertIs(result.tzinfo, tz) + self.assertEqual(result, datetime.datetime(2025, 12, 15, 19, 16, tzinfo=tz)) + + # must test loosely for fallback to system local timezone + result = mod.localtime(dt) + self.assertIsInstance(result, datetime.datetime) + self.assertIsInstance(result.tzinfo, datetime.tzinfo) + self.assertEqual(result.year, 2025) + self.assertEqual(result.month, 12) + def test_current_time(self): + tz = datetime.timezone(-datetime.timedelta(hours=5)) - # has tzinfo by default - dt = mod.localtime() - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNotNone(dt.tzinfo) - now = datetime.datetime.now() - self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp())) + # overriding local_zone + result = mod.localtime(local_zone=tz) + self.assertIsInstance(result, datetime.datetime) + self.assertIs(result.tzinfo, tz) + + # fallback to system local timezone + result = mod.localtime() + self.assertIsInstance(result, datetime.datetime) + self.assertIsInstance(result.tzinfo, datetime.tzinfo) + self.assertIsNot(result.tzinfo, tz) + + def test_want_tzinfo(self): + + # wants tzinfo implicitly + result = mod.localtime() + self.assertIsInstance(result.tzinfo, datetime.tzinfo) + + # wants tzinfo explicitly + result = mod.localtime(want_tzinfo=True) + self.assertIsInstance(result.tzinfo, datetime.tzinfo) # no tzinfo - dt = mod.localtime(tzinfo=False) - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNone(dt.tzinfo) - now = datetime.datetime.now() - self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp())) - - def test_convert_with_tzinfo(self): - sample = datetime.datetime(2024, 9, 15, 13, 30, tzinfo=datetime.timezone.utc) - - # has tzinfo by default - dt = mod.localtime(sample) - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNotNone(dt.tzinfo) - - # no tzinfo - dt = mod.localtime(sample, tzinfo=False) - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNone(dt.tzinfo) - - def test_convert_without_tzinfo(self): - sample = datetime.datetime(2024, 9, 15, 13, 30) - - # has tzinfo by default - dt = mod.localtime(sample) - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNotNone(dt.tzinfo) - - # no tzinfo - dt = mod.localtime(sample, tzinfo=False) - self.assertIsInstance(dt, datetime.datetime) - self.assertIsNone(dt.tzinfo) + result = mod.localtime(want_tzinfo=False) + self.assertIsNone(result.tzinfo) class TestMakeUTC(TestCase):