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/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/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/pyproject.toml b/pyproject.toml index fec7d85..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"}] @@ -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..044331f 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,117 @@ 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). + + 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() + 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()`. + + 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) @@ -795,7 +896,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 +905,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..f232767 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -190,56 +190,93 @@ 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 - handler. + handler. For usage examples see :ref:`convert-to-localtime`. See also :func:`make_utc()` which is sort of the inverse. :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): @@ -250,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. diff --git a/tests/test_app.py b/tests/test_app.py index 8bafb2c..cd2236b 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 @@ -55,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): @@ -426,6 +426,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 +590,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):