3
0
Fork 0

feat: add "complete" timezone support

at least for now, this is all we need i think..  if nothing
configured, will fallback to default system local timezone.  or can
configure the default, or alternate(s) as needed.

some day when we drop support for python 3.8, can also remove the
python-dateutil dependency..
This commit is contained in:
Lance Edgar 2025-12-16 21:28:40 -06:00
parent dac91406c7
commit 0ffc72f766
6 changed files with 396 additions and 87 deletions

View file

@ -30,6 +30,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = { intersphinx_mapping = {
"alembic": ("https://alembic.sqlalchemy.org/en/latest/", None), "alembic": ("https://alembic.sqlalchemy.org/en/latest/", None),
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
"humanize": ("https://humanize.readthedocs.io/en/stable/", None), "humanize": ("https://humanize.readthedocs.io/en/stable/", None),
"mako": ("https://docs.makotemplates.org/en/latest/", None), "mako": ("https://docs.makotemplates.org/en/latest/", None),
"packaging": ("https://packaging.python.org/en/latest/", None), "packaging": ("https://packaging.python.org/en/latest/", None),

View file

@ -27,6 +27,7 @@ classifiers = [
requires-python = ">= 3.8" requires-python = ">= 3.8"
dependencies = [ dependencies = [
"bcrypt", "bcrypt",
'python-dateutil; python_version < "3.9"',
"humanize", "humanize",
'importlib-metadata; python_version < "3.10"', 'importlib-metadata; python_version < "3.10"',
"importlib_resources ; python_version < '3.9'", "importlib_resources ; python_version < '3.9'",
@ -42,7 +43,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
db = ["SQLAlchemy", "alembic", "alembic-postgresql-enum"] db = ["SQLAlchemy", "alembic", "alembic-postgresql-enum"]
docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
tests = ["pylint", "pytest", "pytest-cov", "tox"] tests = ["pylint", "pytest", "pytest-cov", "tox", "python-dateutil"]
[project.scripts] [project.scripts]

View file

@ -25,6 +25,7 @@ WuttJamaican - app handler
""" """
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import datetime
import logging import logging
import os import os
import sys import sys
@ -35,6 +36,7 @@ from importlib.metadata import version
import humanize import humanize
from wuttjamaican.util import ( from wuttjamaican.util import (
get_timezone_by_name,
localtime, localtime,
load_entry_points, load_entry_points,
load_object, load_object,
@ -110,6 +112,7 @@ class AppHandler: # pylint: disable=too-many-public-methods
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.handlers = {} self.handlers = {}
self.timezones = {}
@property @property
def appname(self): def appname(self):
@ -528,19 +531,113 @@ class AppHandler: # pylint: disable=too-many-public-methods
""" """
return make_full_name(*parts) 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 Get the configured (or system default) timezone object.
convenience wrapper around
:func:`~wuttjamaican.util.localtime()`. 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): def make_utc(self, dt=None, tzinfo=False):
""" """
This returns a datetime local to the UTC timezone. It is a This returns a datetime local to the UTC timezone. It is a
convenience wrapper around convenience wrapper around
:func:`~wuttjamaican.util.make_utc()`. :func:`~wuttjamaican.util.make_utc()`.
See also :meth:`localtime()` which is sort of the inverse.
""" """
return make_utc(dt=dt, tzinfo=tzinfo) return make_utc(dt=dt, tzinfo=tzinfo)
@ -795,7 +892,7 @@ class AppHandler: # pylint: disable=too-many-public-methods
return "" return ""
return value.strftime(self.display_format_date) 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. 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 :param value: A :class:`python:datetime.datetime` instance (or
``None``). ``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: if value is None:
return "" return ""
if local:
if not value.tzinfo:
value = self.localtime(value) value = self.localtime(value)
return value.strftime(self.display_format_datetime) return value.strftime(self.display_format_datetime)
def render_error(self, error): def render_error(self, error):

View file

@ -190,10 +190,40 @@ def make_full_name(*parts):
return " ".join(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 Retrieve a timezone object by name.
it will be *zone-aware*.
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 See also the shortcut
:meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app :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. :param dt: Optional :class:`python:datetime.datetime` instance.
If not specified, the current time will be used. If not specified, the current time will be used.
:param tzinfo: Boolean indicating whether the return value should :param from_utc: Boolean indicating whether a naive ``dt`` is
have its :attr:`~python:datetime.datetime.tzinfo` attribute already (effectively) in UTC timezone. Set this to false when
set. This is true by default in which case the return value providing a naive ``dt`` which is already in "local" timezone
will be zone-aware. instead of UTC. This flag is ignored if ``dt`` is zone-aware.
:returns: :class:`python:datetime.datetime` instance in :param want_tzinfo: Boolean indicating whether the resulting
system-local timezone. 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 # use current time if none provided
if dt is None: if dt is None:
dt = datetime.datetime.now(datetime.timezone.utc) 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 # set dt's timezone if needed
if dt.tzinfo: if not dt.tzinfo:
dt = dt.astimezone() # UTC is default assumption unless caller says otherwise
if tzinfo: if from_utc:
return dt dt = dt.replace(tzinfo=datetime.timezone.utc)
return dt.replace(tzinfo=None) 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.. # convert to local timezone
if not tzinfo: if local_zone:
dt = dt.astimezone(local_zone)
else:
dt = dt.astimezone()
# maybe strip tzinfo
if want_tzinfo:
return dt return dt
return dt.replace(tzinfo=None)
# ..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()
def make_utc(dt=None, tzinfo=False): def make_utc(dt=None, tzinfo=False):

View file

@ -16,9 +16,10 @@ from mako.template import Template
import wuttjamaican.enum import wuttjamaican.enum
from wuttjamaican import app as mod from wuttjamaican import app as mod
from wuttjamaican.exc import ConfigurationError
from wuttjamaican.progress import ProgressBase from wuttjamaican.progress import ProgressBase
from wuttjamaican.conf import WuttaConfig 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.testing import FileTestCase, ConfigTestCase
from wuttjamaican.batch import BatchHandler from wuttjamaican.batch import BatchHandler
@ -426,6 +427,80 @@ app_title = WuttaTest
name = self.app.make_full_name("Fred", "", "Flintstone", "") name = self.app.make_full_name("Fred", "", "Flintstone", "")
self.assertEqual(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): def test_localtime(self):
dt = self.app.localtime() dt = self.app.localtime()
self.assertIsInstance(dt, datetime.datetime) self.assertIsInstance(dt, datetime.datetime)
@ -516,15 +591,38 @@ app_title = WuttaTest
self.assertEqual(self.app.render_date(dt), "2024-12-11") self.assertEqual(self.app.render_date(dt), "2024-12-11")
def test_render_datetime(self): 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) # null value
self.assertEqual(self.app.render_datetime(dt), "2024-12-11 08:30+0000") self.assertEqual(self.app.render_datetime(None), "")
dt = datetime.datetime(2024, 12, 11, 8, 30) # naive UTC
text = self.app.render_datetime(dt) dt = datetime.datetime(2024, 12, 17, 1, 12)
# TODO: should override local timezone for more complete test self.assertEqual(
self.assertTrue(text.startswith("2024-12-")) 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): def test_render_error(self):

View file

@ -165,49 +165,122 @@ class TestLoadObject(TestCase):
self.assertIs(result, 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): 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): def test_current_time(self):
tz = datetime.timezone(-datetime.timedelta(hours=5))
# has tzinfo by default # overriding local_zone
dt = mod.localtime() result = mod.localtime(local_zone=tz)
self.assertIsInstance(dt, datetime.datetime) self.assertIsInstance(result, datetime.datetime)
self.assertIsNotNone(dt.tzinfo) self.assertIs(result.tzinfo, tz)
now = datetime.datetime.now()
self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp())) # 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 # no tzinfo
dt = mod.localtime(tzinfo=False) result = mod.localtime(want_tzinfo=False)
self.assertIsInstance(dt, datetime.datetime) self.assertIsNone(result.tzinfo)
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)
class TestMakeUTC(TestCase): class TestMakeUTC(TestCase):