diff --git a/docs/index.rst b/docs/index.rst index c7c64a0..e2ccb8a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,7 @@ Contents glossary narr/install/index narr/config/index + narr/datetime narr/db/index narr/cli/index narr/email/index diff --git a/docs/narr/datetime.rst b/docs/narr/datetime.rst new file mode 100644 index 0000000..7c43bbe --- /dev/null +++ b/docs/narr/datetime.rst @@ -0,0 +1,279 @@ + +=================== + DateTime Behavior +=================== + +There must be a way to handle :class:`~python:datetime.datetime` data, +such that we can keep straight any timezone(s) which may be involved +for the app. + +As a rule, we store datetime values as "naive/UTC" within the +:term:`app database`, and convert to "aware/local" as needed for +display to the user etc. + +A related rule is that any *naive* datetime is assumed to be UTC. If +you have a naive/local value then you should convert it to aware/local +or else the framework logic will misinterpret it. (See below, +:ref:`convert-to-localtime`.) + +With these rules in place, the workhorse methods are: + +* :meth:`~wuttjamaican.app.AppHandler.localtime()` +* :meth:`~wuttjamaican.app.AppHandler.make_utc()` +* :meth:`~wuttjamaican.app.AppHandler.render_datetime()` + + +Time Zone Config/Lookup +----------------------- + +Technically no config is required; the default timezone can be gleaned +from the OS:: + + # should always return *something* :) + tz_default = app.get_timezone() + +The default (aka. local) timezone is used by +:meth:`~wuttjamaican.app.AppHandler.localtime()` and therefore also +:meth:`~wuttjamaican.app.AppHandler.render_datetime()`. + +Config can override the default/local timezone; it's assumed most apps +will do this. If desired, other alternate timezone(s) may be +configured as well: + +.. code-block:: ini + + [wutta] + timezone.default = America/Chicago + timezone.eastcoast = America/New_York + timezone.westcoast = America/Los_Angeles + +Corresponding :class:`python:datetime.tzinfo` objects can be fetched +via :meth:`~wuttjamaican.app.AppHandler.get_timezone()`:: + + tz_default = app.get_timezone() # => America/Chicago + + tz_eastcoast = app.get_timezone("eastcoast") + tz_westcoast = app.get_timezone("westcoast") + + +UTC vs. Local Time +------------------ + +Since we store values as naive/UTC, but display to the user as +aware/local, we often need to convert values between these (and +related) formats. + + +.. _convert-to-utc: + +Convert to UTC +~~~~~~~~~~~~~~ + +When a datetime value is written to the app DB, it must be naive/UTC. + +Below are 4 examples for converting values to UTC time zone. In +short, use :meth:`~wuttjamaican.app.AppHandler.make_utc()` - but if +providing a naive value, it must already be UTC! (Because *all* naive +values are assumed to be UTC. Provide zone-aware values when +necessary to avoid confusion.) + +These examples assume ``America/Chicago`` (UTC-0600) for the "local" +timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):: + + # naive/UTC => naive/UTC + # (nb. this conversion is not actually needed of course, but we + # show the example to be thorough) + # nb. value has no timezone but is already correct (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15) + utc = app.make_utc(dt) + + # aware/UTC => naive/UTC + # nb. value has expicit timezone, w/ correct time (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc) + utc = app.make_utc(dt) + + # aware/local => naive/UTC + tzlocal = app.get_timezone() + # nb. value has expicit timezone, w/ correct time (2:15 PM local) + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal) + utc = app.make_utc(dt) + +If your value is naive/local then you can't simply pass it to +:meth:`~wuttjamaican.app.AppHandler.make_utc()` - since that assumes +naive values are already UTC. (Again, *all* naive values are assumed +to be UTC.) + +Instead, first call :meth:`~wuttjamaican.app.AppHandler.localtime()` +with ``from_utc=False`` to add local time zone awareness:: + + # naive/local => naive/UTC + # nb. value has no timezone but is correct for local zone (2:15 PM) + dt = datetime.datetime(2025, 12, 16, 14, 15) + # must first convert, and be sure to specify it's *not* UTC + # (in practice this just sets the local timezone) + dt = app.localtime(dt, from_utc=False) + # value is now "aware/local" so can proceed + utc = app.make_utc(dt) + +The result of all examples shown above (8:15 PM UTC):: + + >>> utc + datetime.datetime(2025, 12, 16, 20, 15) + + +.. _convert-to-localtime: + +Convert to Local Time +~~~~~~~~~~~~~~~~~~~~~ + +When a datetime value is read from the app DB, it must be converted +(from naive/UTC) to aware/local for display to user. + +Below are 4 examples for converting values to local time zone. In +short, use :meth:`~wuttjamaican.app.AppHandler.localtime()` - but if +providing a naive value, you should specify ``from_utc`` param as +needed. + +These examples assume ``America/Chicago`` (UTC-0600) for the "local" +timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):: + + # naive/UTC => aware/local + # nb. value has no timezone but is already correct (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15) + # nb. can omit from_utc since it is assumed for naive values + local = app.localtime(dt) + # nb. or, specify it explicitly anyway + local = app.localtime(dt, from_utc=True) + + # aware/UTC => aware/local + # nb. value has expicit timezone, w/ correct time (8:15 PM UTC) + dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc) + local = app.localtime(dt) + + # aware/local => aware/local + # (nb. this conversion is not actually needed of course, but we + # show the example to be thorough) + tzlocal = app.get_timezone() + # nb. value has expicit timezone, w/ correct time (2:15 PM local) + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal) + # nb. the input and output values are the same here, both aware/local + local = app.localtime(dt) + +If your value is naive/local then you can't simply pass it to +:meth:`~wuttjamaican.app.AppHandler.localtime()` with no qualifiers - +since that assumes naive values are already UTC by default. + +Instead specify ``from_utc=False`` to ensure the value is interpreted +correctly:: + + # naive/local => aware/local + # nb. value has no timezone but is correct for local zone (2:15 PM) + dt = datetime.datetime(2025, 12, 16, 14, 15) + # nb. must specify from_utc to avoid misinterpretation + local = app.localtime(dt, from_utc=False) + +The result of all examples shown above (2:15 PM local):: + + >>> local + datetime.datetime(2025, 12, 16, 14, 15, tzinfo=zoneinfo.ZoneInfo("America/Chicago")) + + +Displaying to the User +---------------------- + +Whenever a datetime should be displayed to the user, call +:meth:`~wuttjamaican.app.AppHandler.render_datetime()`. That will (by +default) convert the value to aware/local and then render it using a +common format. + +(Once again, this will *not* work for naive/local values. Those must +be explicitly converted to aware/local since the framework assumes +*all* naive values are in UTC.) + +You can specify ``local=False`` when calling ``render_datetime()`` to +avoid its default conversion. + +See also :ref:`convert-to-localtime` (above) for examples of how to +convert any value to aware/local. + +.. code-block:: python + + # naive/UTC + dt = app.make_utc() + print(app.render_datetime(dt)) + + # aware/UTC + dt = app.make_utc(tzinfo=True) + print(app.render_datetime(dt)) + + # aware/local + dt = app.localtime() + print(app.render_datetime(dt)) + + # naive/local + dt = datetime.datetime.now() + # nb. must explicitly convert to aware/local + dt = app.localtime(dt, from_utc=False) + # nb. can skip redundant conversion via local=False + print(app.render_datetime(dt, local=False)) + + +Within the Database +------------------- + +This section describes storage and access details for datetime values +within the :term:`app database`. + + +Column Type +~~~~~~~~~~~ + +There is not a consistent/simple way to store timezone for datetime +values in all database backends. Therefore we must always store the +values as naive/UTC so app logic can reliably interpret them. (Hence +that particular rule.) + +All built-in :term:`data models ` use the +:class:`~sqlalchemy:sqlalchemy.types.DateTime` column type (for +datetime fields), with its default behavior. Any app schema +extensions should (usually) do the same. + + +Writing to the DB +~~~~~~~~~~~~~~~~~ + +When a datetime value is written to the app DB, it must be naive/UTC. + +See :ref:`convert-to-utc` (above) for examples of how to convert any +value to naive/UTC. + +Apps typically "write" data via the ORM. Regardless, the key point is +that you should only pass naive/UTC values into the DB:: + + model = app.model + session = app.make_session() + + # obtain aware/local value (for example) + tz = app.get_timezone() + dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tz) + + # convert to naive/UTC when passing to DB + sprocket = model.Sprocket() + sprocket.some_dt_attr = app.make_utc(dt) + sprocket.created = app.make_utc() # "now" + session.add(sprocket) + + session.commit() + + +Reading from the DB +~~~~~~~~~~~~~~~~~~~ + +Nothing special happens when reading datetime values from the DB; they +will be naive/UTC just like they were written:: + + sprocket = session.get(model.Sprocket, uuid) + + # these will be naive/UTC + dt = sprocket.some_dt_attr + dt2 = sprocket.created diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 418fe28..044331f 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -626,6 +626,8 @@ class AppHandler: # pylint: disable=too-many-public-methods :meth:`get_timezone()` to override the ``local_zone`` param (unless caller specifies that). + For usage examples see :ref:`convert-to-localtime`. + See also :meth:`make_utc()` which is sort of the inverse. """ kw["local_zone"] = local_zone or self.get_timezone() @@ -637,6 +639,8 @@ class AppHandler: # pylint: disable=too-many-public-methods convenience wrapper around :func:`~wuttjamaican.util.make_utc()`. + For usage examples see :ref:`convert-to-utc`. + See also :meth:`localtime()` which is sort of the inverse. """ return make_utc(dt=dt, tzinfo=tzinfo) diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 4a4d41f..f232767 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -227,7 +227,7 @@ def localtime(dt=None, from_utc=True, want_tzinfo=True, local_zone=None): See also the shortcut :meth:`~wuttjamaican.app.AppHandler.localtime()` method on the app - handler. + handler. For usage examples see :ref:`convert-to-localtime`. See also :func:`make_utc()` which is sort of the inverse. @@ -287,7 +287,7 @@ def make_utc(dt=None, tzinfo=False): See also the shortcut :meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app - handler. + handler. For usage examples see :ref:`convert-to-utc`. See also :func:`localtime()` which is sort of the inverse.