From 0ffc72f76626fc3d31fe1f27b18800271d3ebc5e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 Dec 2025 21:28:40 -0600 Subject: [PATCH 1/4] 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.. --- docs/conf.py | 1 + pyproject.toml | 3 +- src/wuttjamaican/app.py | 119 ++++++++++++++++++++++++++++--- src/wuttjamaican/util.py | 99 +++++++++++++++++--------- tests/test_app.py | 114 +++++++++++++++++++++++++++--- tests/test_util.py | 147 +++++++++++++++++++++++++++++---------- 6 files changed, 396 insertions(+), 87 deletions(-) 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): From 8c48a3f31848726fb5abfa8cd7ef02fcf2c0190b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Dec 2025 03:48:19 -0600 Subject: [PATCH 2/4] docs: add section for datetime behavior, make_utc/localtime etc. --- docs/index.rst | 1 + docs/narr/datetime.rst | 279 +++++++++++++++++++++++++++++++++++++++ src/wuttjamaican/app.py | 4 + src/wuttjamaican/util.py | 4 +- 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 docs/narr/datetime.rst diff --git a/docs/index.rst b/docs/index.rst index c7c64a0..e2ccb8a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,7 @@ Contents glossary narr/install/index narr/config/index + narr/datetime narr/db/index narr/cli/index narr/email/index diff --git a/docs/narr/datetime.rst b/docs/narr/datetime.rst new file mode 100644 index 0000000..7c43bbe --- /dev/null +++ b/docs/narr/datetime.rst @@ -0,0 +1,279 @@ + +=================== + DateTime Behavior +=================== + +There must be a way to handle :class:`~python:datetime.datetime` data, +such that we can keep straight any timezone(s) which may be involved +for the app. + +As a rule, we store datetime values as "naive/UTC" within the +:term:`app database`, and convert to "aware/local" as needed for +display to the user etc. + +A related rule is that any *naive* datetime is assumed to be UTC. If +you have a naive/local value then you should convert it to aware/local +or else the framework logic will misinterpret it. (See below, +:ref:`convert-to-localtime`.) + +With these rules in place, the workhorse methods are: + +* :meth:`~wuttjamaican.app.AppHandler.localtime()` +* :meth:`~wuttjamaican.app.AppHandler.make_utc()` +* :meth:`~wuttjamaican.app.AppHandler.render_datetime()` + + +Time Zone Config/Lookup +----------------------- + +Technically no config is required; the default timezone can be gleaned +from the OS:: + + # should always return *something* :) + tz_default = app.get_timezone() + +The default (aka. local) timezone is used by +:meth:`~wuttjamaican.app.AppHandler.localtime()` and therefore also +:meth:`~wuttjamaican.app.AppHandler.render_datetime()`. + +Config can override the default/local timezone; it's assumed most apps +will do this. If desired, other alternate timezone(s) may be +configured as well: + +.. code-block:: ini + + [wutta] + timezone.default = America/Chicago + timezone.eastcoast = America/New_York + timezone.westcoast = America/Los_Angeles + +Corresponding :class:`python:datetime.tzinfo` objects can be fetched +via :meth:`~wuttjamaican.app.AppHandler.get_timezone()`:: + + tz_default = app.get_timezone() # => America/Chicago + + tz_eastcoast = app.get_timezone("eastcoast") + tz_westcoast = app.get_timezone("westcoast") + + +UTC vs. Local Time +------------------ + +Since we store values as naive/UTC, but display to the user as +aware/local, we often need to convert values between these (and +related) formats. + + +.. _convert-to-utc: + +Convert to UTC +~~~~~~~~~~~~~~ + +When a datetime value is written to the app DB, it must be naive/UTC. + +Below are 4 examples for converting values to UTC time zone. In +short, use :meth:`~wuttjamaican.app.AppHandler.make_utc()` - but if +providing a naive value, it must already be UTC! (Because *all* naive +values are assumed to be UTC. Provide zone-aware values when +necessary to avoid confusion.) + +These examples assume ``America/Chicago`` (UTC-0600) for the "local" +timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):: + + # naive/UTC => naive/UTC + # (nb. this conversion is not actually needed of course, but we + # show the example to be thorough) + # nb. value has no timezone but is already correct (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15) + utc = app.make_utc(dt) + + # aware/UTC => naive/UTC + # nb. value has expicit timezone, w/ correct time (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc) + utc = app.make_utc(dt) + + # aware/local => naive/UTC + tzlocal = app.get_timezone() + # nb. value has expicit timezone, w/ correct time (2:15 PM local) + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal) + utc = app.make_utc(dt) + +If your value is naive/local then you can't simply pass it to +:meth:`~wuttjamaican.app.AppHandler.make_utc()` - since that assumes +naive values are already UTC. (Again, *all* naive values are assumed +to be UTC.) + +Instead, first call :meth:`~wuttjamaican.app.AppHandler.localtime()` +with ``from_utc=False`` to add local time zone awareness:: + + # naive/local => naive/UTC + # nb. value has no timezone but is correct for local zone (2:15 PM) + dt = datetime.datetime(2025, 12, 16, 14, 15) + # must first convert, and be sure to specify it's *not* UTC + # (in practice this just sets the local timezone) + dt = app.localtime(dt, from_utc=False) + # value is now "aware/local" so can proceed + utc = app.make_utc(dt) + +The result of all examples shown above (8:15 PM UTC):: + + >>> utc + datetime.datetime(2025, 12, 16, 20, 15) + + +.. _convert-to-localtime: + +Convert to Local Time +~~~~~~~~~~~~~~~~~~~~~ + +When a datetime value is read from the app DB, it must be converted +(from naive/UTC) to aware/local for display to user. + +Below are 4 examples for converting values to local time zone. In +short, use :meth:`~wuttjamaican.app.AppHandler.localtime()` - but if +providing a naive value, you should specify ``from_utc`` param as +needed. + +These examples assume ``America/Chicago`` (UTC-0600) for the "local" +timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):: + + # naive/UTC => aware/local + # nb. value has no timezone but is already correct (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15) + # nb. can omit from_utc since it is assumed for naive values + local = app.localtime(dt) + # nb. or, specify it explicitly anyway + local = app.localtime(dt, from_utc=True) + + # aware/UTC => aware/local + # nb. value has expicit timezone, w/ correct time (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc) + local = app.localtime(dt) + + # aware/local => aware/local + # (nb. this conversion is not actually needed of course, but we + # show the example to be thorough) + tzlocal = app.get_timezone() + # nb. value has expicit timezone, w/ correct time (2:15 PM local) + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal) + # nb. the input and output values are the same here, both aware/local + local = app.localtime(dt) + +If your value is naive/local then you can't simply pass it to +:meth:`~wuttjamaican.app.AppHandler.localtime()` with no qualifiers - +since that assumes naive values are already UTC by default. + +Instead specify ``from_utc=False`` to ensure the value is interpreted +correctly:: + + # naive/local => aware/local + # nb. value has no timezone but is correct for local zone (2:15 PM) + dt = datetime.datetime(2025, 12, 16, 14, 15) + # nb. must specify from_utc to avoid misinterpretation + local = app.localtime(dt, from_utc=False) + +The result of all examples shown above (2:15 PM local):: + + >>> local + datetime.datetime(2025, 12, 16, 14, 15, tzinfo=zoneinfo.ZoneInfo("America/Chicago")) + + +Displaying to the User +---------------------- + +Whenever a datetime should be displayed to the user, call +:meth:`~wuttjamaican.app.AppHandler.render_datetime()`. That will (by +default) convert the value to aware/local and then render it using a +common format. + +(Once again, this will *not* work for naive/local values. Those must +be explicitly converted to aware/local since the framework assumes +*all* naive values are in UTC.) + +You can specify ``local=False`` when calling ``render_datetime()`` to +avoid its default conversion. + +See also :ref:`convert-to-localtime` (above) for examples of how to +convert any value to aware/local. + +.. code-block:: python + + # naive/UTC + dt = app.make_utc() + print(app.render_datetime(dt)) + + # aware/UTC + dt = app.make_utc(tzinfo=True) + print(app.render_datetime(dt)) + + # aware/local + dt = app.localtime() + print(app.render_datetime(dt)) + + # naive/local + dt = datetime.datetime.now() + # nb. must explicitly convert to aware/local + dt = app.localtime(dt, from_utc=False) + # nb. can skip redundant conversion via local=False + print(app.render_datetime(dt, local=False)) + + +Within the Database +------------------- + +This section describes storage and access details for datetime values +within the :term:`app database`. + + +Column Type +~~~~~~~~~~~ + +There is not a consistent/simple way to store timezone for datetime +values in all database backends. Therefore we must always store the +values as naive/UTC so app logic can reliably interpret them. (Hence +that particular rule.) + +All built-in :term:`data models ` use the +:class:`~sqlalchemy:sqlalchemy.types.DateTime` column type (for +datetime fields), with its default behavior. Any app schema +extensions should (usually) do the same. + + +Writing to the DB +~~~~~~~~~~~~~~~~~ + +When a datetime value is written to the app DB, it must be naive/UTC. + +See :ref:`convert-to-utc` (above) for examples of how to convert any +value to naive/UTC. + +Apps typically "write" data via the ORM. Regardless, the key point is +that you should only pass naive/UTC values into the DB:: + + model = app.model + session = app.make_session() + + # obtain aware/local value (for example) + tz = app.get_timezone() + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tz) + + # convert to naive/UTC when passing to DB + sprocket = model.Sprocket() + sprocket.some_dt_attr = app.make_utc(dt) + sprocket.created = app.make_utc() # "now" + session.add(sprocket) + + session.commit() + + +Reading from the DB +~~~~~~~~~~~~~~~~~~~ + +Nothing special happens when reading datetime values from the DB; they +will be naive/UTC just like they were written:: + + sprocket = session.get(model.Sprocket, uuid) + + # these will be naive/UTC + dt = sprocket.some_dt_attr + dt2 = sprocket.created diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 418fe28..044331f 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -626,6 +626,8 @@ class AppHandler: # pylint: disable=too-many-public-methods :meth:`get_timezone()` to override the ``local_zone`` param (unless caller specifies that). + For usage examples see :ref:`convert-to-localtime`. + See also :meth:`make_utc()` which is sort of the inverse. """ kw["local_zone"] = local_zone or self.get_timezone() @@ -637,6 +639,8 @@ class AppHandler: # pylint: disable=too-many-public-methods convenience wrapper around :func:`~wuttjamaican.util.make_utc()`. + For usage examples see :ref:`convert-to-utc`. + See also :meth:`localtime()` which is sort of the inverse. """ return make_utc(dt=dt, tzinfo=tzinfo) diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 4a4d41f..f232767 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -227,7 +227,7 @@ def localtime(dt=None, from_utc=True, want_tzinfo=True, local_zone=None): See also the shortcut :meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app - handler. + handler. For usage examples see :ref:`convert-to-localtime`. See also :func:`make_utc()` which is sort of the inverse. @@ -287,7 +287,7 @@ def make_utc(dt=None, tzinfo=False): See also the shortcut :meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app - handler. + handler. For usage examples see :ref:`convert-to-utc`. See also :func:`localtime()` which is sort of the inverse. From a779be4a81e9eed4b45e98e40954520cc3d3a16f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Dec 2025 10:06:09 -0600 Subject: [PATCH 3/4] test: update deprecated method call --- tests/test_app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index b6f4c7a..cd2236b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -56,8 +56,7 @@ class TestAppHandler(FileTestCase): self.assertIs(obj, UNSPECIFIED) def test_get_appdir(self): - - mockdir = self.mkdir("mockdir") + mockdir = self.mkdtemp() # default appdir with patch.object(sys, "prefix", new=mockdir): From 39014e5a2c59627a4dea38a522fc4a602479fd68 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 17 Dec 2025 10:08:43 -0600 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.25.0=20=E2=86=92=200.26?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916c550..983cc3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.26.0 (2025-12-17) + +### Feat + +- add "complete" timezone support +- add `localtime()` function, app method + +### Fix + +- remove unused import + ## v0.25.0 (2025-12-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 52a5f23..2e48e86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.25.0" +version = "0.26.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]