chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
263
src/everos/component/utils/datetime.py
Normal file
263
src/everos/component/utils/datetime.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""Timezone-aware datetime helpers.
|
||||
|
||||
EverOS follows a **two-zone discipline**:
|
||||
|
||||
* **Storage** (SQLite + LanceDB) is always UTC. Use :func:`get_utc_now`
|
||||
for any ``default_factory`` / write-path timestamp; if you accept a
|
||||
``datetime`` from a caller, normalise with :func:`ensure_utc` before
|
||||
it crosses the persistence boundary.
|
||||
* **Display** (markdown frontmatter, HTTP API response, date buckets for
|
||||
daily-log filenames) uses the configured "display timezone" from
|
||||
:attr:`everos.config.MemorySettings.timezone` (``EVEROS_MEMORY__TIMEZONE``).
|
||||
Use :func:`get_now_with_timezone` / :func:`today_with_timezone` /
|
||||
:func:`to_display_tz` here.
|
||||
|
||||
The display timezone also serves as the **fallback timezone for naive
|
||||
input**: if a caller hands us a string / datetime without offset (e.g.
|
||||
a hand-written ISO timestamp), :func:`from_iso_format` attaches the
|
||||
display timezone before further processing — that matches a human's
|
||||
intuition ("if I didn't say a zone, you should assume my zone").
|
||||
|
||||
Never call :func:`datetime.datetime.now` /
|
||||
:func:`datetime.datetime.utcnow` directly — see
|
||||
:doc:`.claude/rules/datetime-handling`.
|
||||
|
||||
Cache invalidation in tests::
|
||||
|
||||
load_settings.cache_clear()
|
||||
_display_tz.cache_clear()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from functools import cache
|
||||
from typing import Annotated
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import AfterValidator
|
||||
|
||||
_MS_THRESHOLD = 1e12 # ts >= this is treated as milliseconds
|
||||
|
||||
|
||||
@cache
|
||||
def _display_tz() -> _dt.tzinfo:
|
||||
"""Resolve the configured **display timezone** (cached).
|
||||
|
||||
Reads :attr:`everos.config.MemorySettings.timezone`; that field
|
||||
validates the name with :class:`zoneinfo.ZoneInfo` at load time, so
|
||||
by the time we reach here the value is guaranteed valid. This
|
||||
timezone governs:
|
||||
|
||||
1. ISO output rendered in markdown / API responses.
|
||||
2. The fallback zone attached to naive-input datetimes.
|
||||
|
||||
It does **not** govern storage — see :func:`get_utc_now`.
|
||||
"""
|
||||
# Lazy import to avoid pulling in pydantic-settings at module load.
|
||||
from everos.config import load_settings
|
||||
|
||||
return ZoneInfo(load_settings().memory.timezone)
|
||||
|
||||
|
||||
def get_utc_now() -> _dt.datetime:
|
||||
"""Return the current time as a UTC-aware datetime.
|
||||
|
||||
Use for any **storage** write-path (SQLite ``default_factory``,
|
||||
LanceDB row construction, OME event ``ts``, any internal "when
|
||||
did this happen" record). Independent of the display timezone — a
|
||||
new deployment that switches ``EVEROS_MEMORY__TIMEZONE`` will not
|
||||
misalign existing rows.
|
||||
|
||||
Display-side code should use :func:`get_now_with_timezone` instead,
|
||||
or render via :func:`to_display_tz`.
|
||||
"""
|
||||
return _dt.datetime.now(tz=_dt.UTC)
|
||||
|
||||
|
||||
def get_now_with_timezone() -> _dt.datetime:
|
||||
"""Return the current time in the **display timezone** (configured).
|
||||
|
||||
Use for **display** write-paths only — markdown frontmatter values,
|
||||
daily-log date buckets, places where a human will see the literal
|
||||
string. The returned datetime carries the display timezone offset
|
||||
so ``.isoformat()`` produces something like
|
||||
``2026-05-29T14:00:00+08:00``.
|
||||
|
||||
For storage / internal "when did this happen" timestamps use
|
||||
:func:`get_utc_now` instead — display timezone must not bleed into
|
||||
persisted rows.
|
||||
"""
|
||||
return _dt.datetime.now(tz=_display_tz())
|
||||
|
||||
|
||||
def today_with_timezone() -> _dt.date:
|
||||
"""Return today's date in the **display timezone**.
|
||||
|
||||
Use this anywhere a *date bucket* is needed (e.g. daily-log file
|
||||
boundaries) — it normalises ``get_now_with_timezone().date()`` so
|
||||
the timezone fallback rules are applied consistently.
|
||||
"""
|
||||
return get_now_with_timezone().date()
|
||||
|
||||
|
||||
def ensure_utc(d: _dt.datetime | None) -> _dt.datetime | None:
|
||||
"""Normalise any datetime to UTC at the **storage boundary**.
|
||||
|
||||
Semantics:
|
||||
|
||||
* ``None`` → ``None`` (nullable-column convenience: lets callers
|
||||
pipe ``ensure_utc(row.last_attempt_at)`` without an outer guard).
|
||||
* Aware input → ``astimezone(UTC)``.
|
||||
* **Naive input → assume UTC** (attach ``tzinfo=UTC``); no
|
||||
display-tz fallback.
|
||||
|
||||
Why naive→UTC rather than naive→display→UTC? Every caller of this
|
||||
function sits at the storage boundary, and the dominant naive
|
||||
source is SQLite reads: SQLAlchemy strips tz on write so what
|
||||
comes back is a naive value whose bytes are UTC. Treating those
|
||||
naive reads as display-tz would drift by the configured offset on
|
||||
every round trip — exactly the bug Q2 prevents.
|
||||
|
||||
Caller-supplied datetimes that may genuinely be naive in display
|
||||
tz (e.g. ISO strings from HTTP request bodies that omitted the
|
||||
offset) should be funnelled through :func:`from_iso_format` first,
|
||||
which encodes the "if you didn't say a zone, assume your zone"
|
||||
rule. The aware result then passes through ``ensure_utc`` as a
|
||||
pure ``astimezone(UTC)``.
|
||||
|
||||
Use the :data:`UtcDatetime` ``Annotated`` type to apply this
|
||||
automatically on Pydantic model fields.
|
||||
"""
|
||||
if d is None:
|
||||
return None
|
||||
if d.tzinfo is None:
|
||||
return d.replace(tzinfo=_dt.UTC)
|
||||
return d.astimezone(_dt.UTC)
|
||||
|
||||
|
||||
def to_display_tz(d: _dt.datetime | None) -> _dt.datetime | None:
|
||||
"""Convert a datetime to the **display timezone** (configured).
|
||||
|
||||
Used at the **response render boundary**: any datetime leaving the
|
||||
system through an API response or markdown body passes through
|
||||
here so the user sees their wall-clock time with the matching
|
||||
``+HH:MM`` offset.
|
||||
|
||||
* ``None`` → ``None`` (nullable-column convenience).
|
||||
* Naive input is treated as already display-tz local (the fallback
|
||||
rule) — attach the zone and return as-is.
|
||||
* Aware input is ``astimezone(...)``-d to the display tz.
|
||||
"""
|
||||
if d is None:
|
||||
return None
|
||||
if d.tzinfo is None:
|
||||
return d.replace(tzinfo=_display_tz())
|
||||
return d.astimezone(_display_tz())
|
||||
|
||||
|
||||
UtcDatetime = Annotated[_dt.datetime, AfterValidator(ensure_utc)]
|
||||
"""Pydantic-friendly ``datetime`` type that normalises to UTC.
|
||||
|
||||
Apply to any SQLModel / Pydantic ``datetime`` field that maps to a
|
||||
storage column. Both INSERT default values and post-read values pass
|
||||
through :func:`ensure_utc`, so SQLite's tz-stripping behaviour is
|
||||
neutralised: rows go in as UTC and come out as UTC-aware.
|
||||
|
||||
Usage::
|
||||
|
||||
from everos.component.utils.datetime import UtcDatetime, get_utc_now
|
||||
|
||||
class MyRow(BaseTable, table=True):
|
||||
happened_at: UtcDatetime = Field(default_factory=get_utc_now)
|
||||
"""
|
||||
|
||||
|
||||
def from_timestamp(ts: int | float) -> _dt.datetime:
|
||||
"""Parse a Unix timestamp into a timezone-aware datetime.
|
||||
|
||||
Auto-detects seconds vs milliseconds: values ``>= 1e12`` are treated as
|
||||
milliseconds. Returned datetime is in the default timezone.
|
||||
"""
|
||||
seconds = ts / 1000.0 if ts >= _MS_THRESHOLD else float(ts)
|
||||
return _dt.datetime.fromtimestamp(seconds, tz=_display_tz())
|
||||
|
||||
|
||||
def from_iso_format(value: _dt.datetime | int | float | str) -> _dt.datetime:
|
||||
"""Parse a value into a timezone-aware datetime (strict).
|
||||
|
||||
Accepted inputs:
|
||||
* ``datetime`` — naive values get the default timezone attached.
|
||||
* ``int`` / ``float`` — Unix timestamp (auto-detect seconds vs ms).
|
||||
* ``str`` — ISO-8601, including ``"Z"`` suffix for UTC.
|
||||
|
||||
Raises:
|
||||
TypeError: On unsupported input type.
|
||||
ValueError: On malformed string / negative timestamp.
|
||||
"""
|
||||
if isinstance(value, _dt.datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=_display_tz())
|
||||
return value
|
||||
if isinstance(value, bool): # bool is an int subclass — reject explicitly
|
||||
raise TypeError("from_iso_format does not accept bool")
|
||||
if isinstance(value, int | float):
|
||||
return from_timestamp(value)
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
# Python's fromisoformat accepts "+HH:MM" but not the "Z" suffix; map it.
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
parsed = _dt.datetime.fromisoformat(s)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=_display_tz())
|
||||
return parsed
|
||||
raise TypeError(
|
||||
f"from_iso_format: unsupported type {type(value).__name__}; "
|
||||
"expected datetime / int / float / str"
|
||||
)
|
||||
|
||||
|
||||
def to_iso_format(
|
||||
value: _dt.datetime | int | float | str | None,
|
||||
) -> str | None:
|
||||
"""Render a value as an ISO-8601 string (timezone-aware).
|
||||
|
||||
Accepted inputs:
|
||||
* ``None`` — returns ``None`` (nullable column convenience).
|
||||
* ``datetime`` — rendered as-is (must already be tz-aware).
|
||||
* ``int`` / ``float`` — interpreted via :func:`from_timestamp`.
|
||||
* ``str`` — re-validated through :func:`from_iso_format`.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, _dt.datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, bool): # bool is an int subclass
|
||||
raise TypeError("to_iso_format does not accept bool")
|
||||
if isinstance(value, int | float):
|
||||
return from_timestamp(value).isoformat()
|
||||
if isinstance(value, str):
|
||||
if not value:
|
||||
return None
|
||||
return from_iso_format(value).isoformat()
|
||||
raise TypeError(
|
||||
f"to_iso_format: unsupported type {type(value).__name__}; "
|
||||
"expected datetime / int / float / str / None"
|
||||
)
|
||||
|
||||
|
||||
def to_date_str(d: _dt.datetime | None) -> str | None:
|
||||
"""Render the date portion of a datetime as ``YYYY-MM-DD``.
|
||||
|
||||
Accepts ``None`` for nullable database columns. When the input is
|
||||
already a :class:`datetime.date`, call ``d.isoformat()`` directly.
|
||||
"""
|
||||
if d is None:
|
||||
return None
|
||||
return d.date().isoformat()
|
||||
|
||||
|
||||
def to_timestamp_ms(d: _dt.datetime) -> int:
|
||||
"""Convert a datetime to a Unix timestamp in milliseconds."""
|
||||
return int(d.timestamp() * 1000)
|
||||
Reference in New Issue
Block a user