3
0
Fork 0

Compare commits

..

No commits in common. "39014e5a2c59627a4dea38a522fc4a602479fd68" and "dac91406c7f307c35d86ebc364596e6d5fac04bf" have entirely different histories.

9 changed files with 92 additions and 695 deletions

View file

@ -5,17 +5,6 @@ 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/) 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). 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) ## v0.25.0 (2025-12-15)
### Feat ### Feat

View file

@ -30,7 +30,6 @@ 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

@ -59,7 +59,6 @@ Contents
glossary glossary
narr/install/index narr/install/index
narr/config/index narr/config/index
narr/datetime
narr/db/index narr/db/index
narr/cli/index narr/cli/index
narr/email/index narr/email/index

View file

@ -1,279 +0,0 @@
===================
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 <data model>` 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

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttJamaican" name = "WuttJamaican"
version = "0.26.0" version = "0.25.0"
description = "Base package for Wutta Framework" description = "Base package for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -27,7 +27,6 @@ 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'",
@ -43,7 +42,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", "python-dateutil"] tests = ["pylint", "pytest", "pytest-cov", "tox"]
[project.scripts] [project.scripts]

View file

@ -25,7 +25,6 @@ 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
@ -36,7 +35,6 @@ 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,
@ -112,7 +110,6 @@ 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):
@ -531,117 +528,19 @@ class AppHandler: # pylint: disable=too-many-public-methods
""" """
return make_full_name(*parts) return make_full_name(*parts)
def get_timezone(self, key="default"): def localtime(self, dt=None, tzinfo=True):
""" """
Get the configured (or system default) timezone object. This returns a datetime in the system-local timezone. It is a
convenience wrapper around
This checks config for a setting which corresponds to the :func:`~wuttjamaican.util.localtime()`.
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
""" """
if key not in self.timezones: return localtime(dt=dt, tzinfo=tzinfo)
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): 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()`.
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) return make_utc(dt=dt, tzinfo=tzinfo)
@ -896,7 +795,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, local=True): def render_datetime(self, value):
""" """
Return a human-friendly display string for the given datetime. Return a human-friendly display string for the given datetime.
@ -905,16 +804,14 @@ 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``).
:param local: By default the ``value`` will first be passed to :returns: Display string.
: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,94 +190,57 @@ def make_full_name(*parts):
return " ".join(parts) return " ".join(parts)
def get_timezone_by_name(tzname): def localtime(dt=None, tzinfo=True):
""" """
Retrieve a timezone object by name. This returns a datetime in the system-local timezone. By default
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
handler. For usage examples see :ref:`convert-to-localtime`. handler.
See also :func:`make_utc()` which is sort of the inverse. See also :func:`make_utc()` which is sort of the inverse.
: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 from_utc: Boolean indicating whether a naive ``dt`` is :param tzinfo: Boolean indicating whether the return value should
already (effectively) in UTC timezone. Set this to false when have its :attr:`~python:datetime.datetime.tzinfo` attribute
providing a naive ``dt`` which is already in "local" timezone set. This is true by default in which case the return value
instead of UTC. This flag is ignored if ``dt`` is zone-aware. will be zone-aware.
:param want_tzinfo: Boolean indicating whether the resulting :returns: :class:`python:datetime.datetime` instance in
datetime should have its system-local timezone.
: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)
# 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)
# convert to local timezone
if local_zone:
dt = dt.astimezone(local_zone)
else:
dt = dt.astimezone() dt = dt.astimezone()
if tzinfo:
# maybe strip tzinfo
if want_tzinfo:
return dt return dt
return dt.replace(tzinfo=None) 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)
# naive value returned as-is..
if not 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()
def make_utc(dt=None, tzinfo=False): def make_utc(dt=None, tzinfo=False):
""" """
@ -287,7 +250,7 @@ def make_utc(dt=None, tzinfo=False):
See also the shortcut See also the shortcut
:meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app :meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app
handler. For usage examples see :ref:`convert-to-utc`. handler.
See also :func:`localtime()` which is sort of the inverse. See also :func:`localtime()` which is sort of the inverse.

View file

@ -16,10 +16,9 @@ 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, get_timezone_by_name from wuttjamaican.util import UNSPECIFIED
from wuttjamaican.testing import FileTestCase, ConfigTestCase from wuttjamaican.testing import FileTestCase, ConfigTestCase
from wuttjamaican.batch import BatchHandler from wuttjamaican.batch import BatchHandler
@ -56,7 +55,8 @@ class TestAppHandler(FileTestCase):
self.assertIs(obj, UNSPECIFIED) self.assertIs(obj, UNSPECIFIED)
def test_get_appdir(self): def test_get_appdir(self):
mockdir = self.mkdtemp()
mockdir = self.mkdir("mockdir")
# default appdir # default appdir
with patch.object(sys, "prefix", new=mockdir): with patch.object(sys, "prefix", new=mockdir):
@ -426,80 +426,6 @@ 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)
@ -590,38 +516,15 @@ 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):
tzlocal = get_timezone_by_name("America/Los_Angeles")
with patch.object(self.app, "get_timezone", return_value=tzlocal):
# null value
self.assertEqual(self.app.render_datetime(None), "") self.assertEqual(self.app.render_datetime(None), "")
# naive UTC dt = datetime.datetime(2024, 12, 11, 8, 30, tzinfo=datetime.timezone.utc)
dt = datetime.datetime(2024, 12, 17, 1, 12) self.assertEqual(self.app.render_datetime(dt), "2024-12-11 08:30+0000")
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, 11, 8, 30)
dt = datetime.datetime(2024, 12, 17, 1, 12, tzinfo=datetime.timezone.utc) text = self.app.render_datetime(dt)
self.assertEqual( # TODO: should override local timezone for more complete test
self.app.render_datetime(dt, local=True), "2024-12-16 17:12-0800" self.assertTrue(text.startswith("2024-12-"))
)
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,122 +165,49 @@ 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))
# overriding local_zone # has tzinfo by default
result = mod.localtime(local_zone=tz) dt = mod.localtime()
self.assertIsInstance(result, datetime.datetime) self.assertIsInstance(dt, datetime.datetime)
self.assertIs(result.tzinfo, tz) self.assertIsNotNone(dt.tzinfo)
now = datetime.datetime.now()
# fallback to system local timezone self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp()))
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
result = mod.localtime(want_tzinfo=False) dt = mod.localtime(tzinfo=False)
self.assertIsNone(result.tzinfo) 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)
class TestMakeUTC(TestCase): class TestMakeUTC(TestCase):