diff --git a/CHANGELOG.md b/CHANGELOG.md index 983cc3d..916c550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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 81229b2..eb61e17 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,6 @@ 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 e2ccb8a..c7c64a0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,7 +59,6 @@ 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 deleted file mode 100644 index 7c43bbe..0000000 --- a/docs/narr/datetime.rst +++ /dev/null @@ -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 ` 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 2e48e86..fec7d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.26.0" +version = "0.25.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -27,7 +27,6 @@ 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'", @@ -43,7 +42,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", "python-dateutil"] +tests = ["pylint", "pytest", "pytest-cov", "tox"] [project.scripts] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 044331f..69e6218 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -25,7 +25,6 @@ WuttJamaican - app handler """ # pylint: disable=too-many-lines -import datetime import logging import os import sys @@ -36,7 +35,6 @@ from importlib.metadata import version import humanize from wuttjamaican.util import ( - get_timezone_by_name, localtime, load_entry_points, load_object, @@ -112,7 +110,6 @@ class AppHandler: # pylint: disable=too-many-public-methods def __init__(self, config): self.config = config self.handlers = {} - self.timezones = {} @property def appname(self): @@ -531,117 +528,19 @@ class AppHandler: # pylint: disable=too-many-public-methods """ 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 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 + This returns a datetime in the system-local timezone. It is a + convenience wrapper around + :func:`~wuttjamaican.util.localtime()`. """ - 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) + return localtime(dt=dt, tzinfo=tzinfo) 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) @@ -896,7 +795,7 @@ class AppHandler: # pylint: disable=too-many-public-methods return "" 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. @@ -905,16 +804,14 @@ class AppHandler: # pylint: disable=too-many-public-methods :param value: A :class:`python:datetime.datetime` instance (or ``None``). - :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. + :returns: Display string. """ if value is None: return "" - if local: + + if not value.tzinfo: 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 f232767..2f8e509 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -190,93 +190,56 @@ def make_full_name(*parts): return " ".join(parts) -def get_timezone_by_name(tzname): +def localtime(dt=None, tzinfo=True): """ - 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*. + This returns a datetime in the system-local timezone. By default + it will be *zone-aware*. See also the shortcut :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. :param dt: Optional :class:`python:datetime.datetime` instance. If not specified, the current time will be used. - :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. + :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 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. + :returns: :class:`python:datetime.datetime` instance in + system-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) - - # 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() + if tzinfo: + return dt + return dt.replace(tzinfo=None) - # maybe strip tzinfo - if want_tzinfo: + # 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 - return dt.replace(tzinfo=None) + + # ..unless tzinfo is wanted, in which case this assumes naive + # value is in the UTC timezone + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt.astimezone() def make_utc(dt=None, tzinfo=False): @@ -287,7 +250,7 @@ def make_utc(dt=None, tzinfo=False): See also the shortcut :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. diff --git a/tests/test_app.py b/tests/test_app.py index cd2236b..8bafb2c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,10 +16,9 @@ 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, get_timezone_by_name +from wuttjamaican.util import UNSPECIFIED from wuttjamaican.testing import FileTestCase, ConfigTestCase from wuttjamaican.batch import BatchHandler @@ -56,7 +55,8 @@ class TestAppHandler(FileTestCase): self.assertIs(obj, UNSPECIFIED) def test_get_appdir(self): - mockdir = self.mkdtemp() + + mockdir = self.mkdir("mockdir") # default appdir with patch.object(sys, "prefix", new=mockdir): @@ -426,80 +426,6 @@ 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) @@ -590,38 +516,15 @@ app_title = WuttaTest self.assertEqual(self.app.render_date(dt), "2024-12-11") def test_render_datetime(self): - tzlocal = get_timezone_by_name("America/Los_Angeles") - with patch.object(self.app, "get_timezone", return_value=tzlocal): + self.assertEqual(self.app.render_datetime(None), "") - # null value - self.assertEqual(self.app.render_datetime(None), "") + 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") - # 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" - ) + 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-")) def test_render_error(self): diff --git a/tests/test_util.py b/tests/test_util.py index 398a23e..a73b681 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -165,122 +165,49 @@ 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)) - # 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) + # 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())) # no tzinfo - result = mod.localtime(want_tzinfo=False) - self.assertIsNone(result.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) class TestMakeUTC(TestCase):