From b22a5e45405c7ff723f80f2b361b957e349dc6d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:11:52 +0900 Subject: [PATCH 01/13] test(models): reproduce naive datetime default deprecation --- .../tests/test_model_timestamp_defaults.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/tests/test_model_timestamp_defaults.py diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py new file mode 100644 index 000000000..c1fcaef91 --- /dev/null +++ b/backend/tests/test_model_timestamp_defaults.py @@ -0,0 +1,58 @@ +"""Timezone-aware datetime default guard for ORM columns (naruon#1041). + +Python 3.12+ deprecates ``datetime.datetime.utcnow``: it returns a naive +timestamp whose ``DeprecationWarning`` is fatal under the CI app test +suite's ``PYTHONWARNINGS=error``. Every column default / onupdate that +produces a ``datetime`` must therefore be timezone-aware. + +This guard fails when any mapped column default yields a naive datetime, +so a future model cannot silently reintroduce the deprecation. +""" + +import datetime + +from db.models import Base + + +def _datetime_default_callables(): + for mapper in Base.registry.mappers: + for column in mapper.columns: + for kind in ("default", "onupdate"): + column_default = getattr(column, kind, None) + if column_default is None: + continue + arg = getattr(column_default, "arg", None) + if callable(arg): + yield mapper.local_table.name, column.name, kind, arg + + +def _call_with_context(default_callable): + """Evaluate a column default the way SQLAlchemy does. + + SQLAlchemy wraps context-less callables so they accept an execution + context, so try the context form first and fall back to the plain + call form. + """ + try: + return default_callable(None) + except TypeError: + pass + try: + return default_callable() + except TypeError: + return None + + +def test_datetime_column_defaults_are_timezone_aware(): + naive_defaults: list[str] = [] + for table, column, kind, default_callable in _datetime_default_callables(): + value = _call_with_context(default_callable) + if isinstance(value, datetime.datetime) and value.tzinfo is None: + naive_defaults.append(f"{table}.{column} ({kind})") + + assert not naive_defaults, ( + "naive datetime defaults are deprecated (datetime.utcnow) and fatal " + "under PYTHONWARNINGS=error; use " + "lambda: datetime.datetime.now(datetime.timezone.utc): " + + ", ".join(sorted(naive_defaults)) + ) From d965a843cf8f860a508eb44aefe4248828400fff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:11:53 +0900 Subject: [PATCH 02/13] fix(models): use timezone-aware defaults for newsdom provider --- backend/db/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db/models.py b/backend/db/models.py index 98e17eef2..0270a706d 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -321,8 +321,8 @@ class NewsdomProvider(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) updated_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), - default=datetime.datetime.utcnow, - onupdate=datetime.datetime.utcnow, + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), ) From 67b04f02ac43b26b968ff44332fb43932077fd2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:43:18 +0900 Subject: [PATCH 03/13] test(models): fail closed on naive timestamp defaults --- .../tests/test_model_timestamp_defaults.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/tests/test_model_timestamp_defaults.py diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py new file mode 100644 index 000000000..6ffd87242 --- /dev/null +++ b/backend/tests/test_model_timestamp_defaults.py @@ -0,0 +1,57 @@ +"""Timezone-aware datetime default guard for ORM columns (naruon#1041). + +Mapped datetime defaults and on-update callables must return timezone-aware +values. SQLAlchemy normalizes callable column defaults to accept an execution +context, so this guard evaluates the actual mapped callable and deliberately +lets evaluation errors fail the test instead of treating them as non-datetime +values. +""" + +import datetime + +from db.models import Base + + +def _datetime_default_callables(): + for mapper in Base.registry.mappers: + for column in mapper.columns: + for kind in ("default", "onupdate"): + column_default = getattr(column, kind, None) + if column_default is None: + continue + default_callable = getattr(column_default, "arg", None) + if callable(default_callable): + yield mapper.local_table.name, column.name, kind, default_callable + + +def _evaluate_mapped_default(default_callable): + """Evaluate a SQLAlchemy-mapped callable without swallowing its errors.""" + + return default_callable(None) + + +def test_datetime_column_defaults_are_timezone_aware(): + naive_defaults: list[str] = [] + for table, column, kind, default_callable in _datetime_default_callables(): + value = _evaluate_mapped_default(default_callable) + if isinstance(value, datetime.datetime) and value.tzinfo is None: + naive_defaults.append(f"{table}.{column} ({kind})") + + assert not naive_defaults, ( + "naive datetime defaults are deprecated (datetime.utcnow) and fatal " + "under PYTHONWARNINGS=error; use " + "lambda: datetime.datetime.now(datetime.timezone.utc): " + + ", ".join(sorted(naive_defaults)) + ) + + +def test_datetime_default_guard_does_not_swallow_callable_failures(): + def broken_default(_context): + raise TypeError("default evaluation failed") + + try: + _evaluate_mapped_default(broken_default) + except TypeError as exc: + assert str(exc) == "default evaluation failed" + else: + raise AssertionError("default evaluation failures must fail closed") From 433bb3aa306b2d57287f816cc91eb8bccabcc7f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:44:02 +0900 Subject: [PATCH 04/13] fix(models): use timezone-aware Newsdom timestamps --- backend/db/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db/models.py b/backend/db/models.py index 98e17eef2..0270a706d 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -321,8 +321,8 @@ class NewsdomProvider(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) updated_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), - default=datetime.datetime.utcnow, - onupdate=datetime.datetime.utcnow, + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), ) From 3eaeafb2468d5d7e2a4c36f75cece984600632e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 00:41:59 +0900 Subject: [PATCH 05/13] test(models): make timestamp guard exclude non-datetime defaults --- backend/tests/test_model_timestamp_defaults.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 6ffd87242..84664e685 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -55,3 +55,12 @@ def broken_default(_context): assert str(exc) == "default evaluation failed" else: raise AssertionError("default evaluation failures must fail closed") + + +def test_datetime_default_guard_excludes_non_datetime_callable_defaults(): + guarded_columns = { + (table, column, kind) + for table, column, kind, _default_callable in _datetime_default_callables() + } + + assert ("security_audit_events", "event_uid", "default") not in guarded_columns From c3b987650a5f7e5dd2e78557982d3796a9a754dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 00:42:15 +0900 Subject: [PATCH 06/13] fix(models): scope timestamp guard to DateTime columns --- backend/tests/test_model_timestamp_defaults.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 84664e685..9c08761d7 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -9,12 +9,16 @@ import datetime +from sqlalchemy import DateTime + from db.models import Base def _datetime_default_callables(): for mapper in Base.registry.mappers: for column in mapper.columns: + if not isinstance(column.type, DateTime): + continue for kind in ("default", "onupdate"): column_default = getattr(column, kind, None) if column_default is None: From 8f56ffe6951cb1aabeaf2bc82894946057fa4adc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 00:45:04 +0900 Subject: [PATCH 07/13] test(models): reject null-offset tzinfo in timestamp guard --- .../tests/test_model_timestamp_defaults.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 9c08761d7..89dd5ddbc 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -14,6 +14,19 @@ from db.models import Base +class _NullOffsetTimezone(datetime.tzinfo): + """tzinfo stub that is still naive under Python's datetime contract.""" + + def utcoffset(self, _value): + return None + + def dst(self, _value): + return None + + def tzname(self, _value): + return "null-offset" + + def _datetime_default_callables(): for mapper in Base.registry.mappers: for column in mapper.columns: @@ -34,11 +47,15 @@ def _evaluate_mapped_default(default_callable): return default_callable(None) +def _is_datetime_timezone_aware(value: datetime.datetime) -> bool: + return value.tzinfo is not None + + def test_datetime_column_defaults_are_timezone_aware(): naive_defaults: list[str] = [] for table, column, kind, default_callable in _datetime_default_callables(): value = _evaluate_mapped_default(default_callable) - if isinstance(value, datetime.datetime) and value.tzinfo is None: + if isinstance(value, datetime.datetime) and not _is_datetime_timezone_aware(value): naive_defaults.append(f"{table}.{column} ({kind})") assert not naive_defaults, ( @@ -68,3 +85,9 @@ def test_datetime_default_guard_excludes_non_datetime_callable_defaults(): } assert ("security_audit_events", "event_uid", "default") not in guarded_columns + + +def test_timezone_awareness_rejects_tzinfo_with_null_offset(): + value = datetime.datetime(2026, 9, 11, tzinfo=_NullOffsetTimezone()) + + assert not _is_datetime_timezone_aware(value) From a7cca21867eec5116bd70421ec4947a9c00543a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 00:45:20 +0900 Subject: [PATCH 08/13] fix(models): enforce full datetime awareness condition --- backend/tests/test_model_timestamp_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 89dd5ddbc..76bb2f79c 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -48,7 +48,7 @@ def _evaluate_mapped_default(default_callable): def _is_datetime_timezone_aware(value: datetime.datetime) -> bool: - return value.tzinfo is not None + return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None def test_datetime_column_defaults_are_timezone_aware(): From 5d462a538a5ccc8ac0009b042f38163ebddab001 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 01:39:29 +0900 Subject: [PATCH 09/13] test(models): reject non-datetime mapped timestamp defaults --- .../tests/test_model_timestamp_defaults.py | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 76bb2f79c..325b14a72 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -51,18 +51,24 @@ def _is_datetime_timezone_aware(value: datetime.datetime) -> bool: return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None -def test_datetime_column_defaults_are_timezone_aware(): - naive_defaults: list[str] = [] - for table, column, kind, default_callable in _datetime_default_callables(): +def _invalid_datetime_default_entries(entries): + """Return mapped DateTime defaults that violate the timezone contract.""" + + invalid_defaults: list[str] = [] + for table, column, kind, default_callable in entries: value = _evaluate_mapped_default(default_callable) if isinstance(value, datetime.datetime) and not _is_datetime_timezone_aware(value): - naive_defaults.append(f"{table}.{column} ({kind})") + invalid_defaults.append(f"{table}.{column} ({kind})") + return invalid_defaults + + +def test_datetime_column_defaults_are_timezone_aware(): + invalid_defaults = _invalid_datetime_default_entries(_datetime_default_callables()) - assert not naive_defaults, ( - "naive datetime defaults are deprecated (datetime.utcnow) and fatal " - "under PYTHONWARNINGS=error; use " - "lambda: datetime.datetime.now(datetime.timezone.utc): " - + ", ".join(sorted(naive_defaults)) + assert not invalid_defaults, ( + "DateTime defaults/onupdates must return timezone-aware datetime values; " + "use lambda: datetime.datetime.now(datetime.timezone.utc): " + + ", ".join(sorted(invalid_defaults)) ) @@ -87,6 +93,17 @@ def test_datetime_default_guard_excludes_non_datetime_callable_defaults(): assert ("security_audit_events", "event_uid", "default") not in guarded_columns +def test_datetime_default_guard_rejects_non_datetime_results(): + def invalid_default(_context): + return "2026-09-11T00:00:00+00:00" + + invalid_defaults = _invalid_datetime_default_entries( + [("example_table", "updated_at", "default", invalid_default)] + ) + + assert invalid_defaults == ["example_table.updated_at (default)"] + + def test_timezone_awareness_rejects_tzinfo_with_null_offset(): value = datetime.datetime(2026, 9, 11, tzinfo=_NullOffsetTimezone()) From d4c2210752fc0b50e93c36dbadf9daba3a11aca6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 01:39:49 +0900 Subject: [PATCH 10/13] fix(models): fail closed on invalid DateTime default results --- backend/tests/test_model_timestamp_defaults.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 325b14a72..58ec98939 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -52,12 +52,12 @@ def _is_datetime_timezone_aware(value: datetime.datetime) -> bool: def _invalid_datetime_default_entries(entries): - """Return mapped DateTime defaults that violate the timezone contract.""" + """Return mapped DateTime defaults that violate the timestamp contract.""" invalid_defaults: list[str] = [] for table, column, kind, default_callable in entries: value = _evaluate_mapped_default(default_callable) - if isinstance(value, datetime.datetime) and not _is_datetime_timezone_aware(value): + if not isinstance(value, datetime.datetime) or not _is_datetime_timezone_aware(value): invalid_defaults.append(f"{table}.{column} ({kind})") return invalid_defaults @@ -94,14 +94,15 @@ def test_datetime_default_guard_excludes_non_datetime_callable_defaults(): def test_datetime_default_guard_rejects_non_datetime_results(): - def invalid_default(_context): - return "2026-09-11T00:00:00+00:00" + for invalid_value in ("2026-09-11T00:00:00+00:00", 1757548800, None): + def invalid_default(_context, value=invalid_value): + return value - invalid_defaults = _invalid_datetime_default_entries( - [("example_table", "updated_at", "default", invalid_default)] - ) + invalid_defaults = _invalid_datetime_default_entries( + [("example_table", "updated_at", "default", invalid_default)] + ) - assert invalid_defaults == ["example_table.updated_at (default)"] + assert invalid_defaults == ["example_table.updated_at (default)"] def test_timezone_awareness_rejects_tzinfo_with_null_offset(): From 7868d2948cd10fd53c3215943bd626ce16024655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 01:40:19 +0900 Subject: [PATCH 11/13] test(models): keep timestamp guard lint-safe --- backend/tests/test_model_timestamp_defaults.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index 58ec98939..f09355da4 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -57,7 +57,8 @@ def _invalid_datetime_default_entries(entries): invalid_defaults: list[str] = [] for table, column, kind, default_callable in entries: value = _evaluate_mapped_default(default_callable) - if not isinstance(value, datetime.datetime) or not _is_datetime_timezone_aware(value): + invalid_value = not isinstance(value, datetime.datetime) + if invalid_value or not _is_datetime_timezone_aware(value): invalid_defaults.append(f"{table}.{column} ({kind})") return invalid_defaults From acfb4c14f297d3b62a1e77e119c584a72dea05af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:42:41 +0900 Subject: [PATCH 12/13] docs(test): remove stale Postgres issue reference --- backend/tests/test_model_timestamp_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index f09355da4..9a9747d17 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -1,4 +1,4 @@ -"""Timezone-aware datetime default guard for ORM columns (naruon#1041). +"""Timezone-aware datetime default guard for ORM columns. Mapped datetime defaults and on-update callables must return timezone-aware values. SQLAlchemy normalizes callable column defaults to accept an execution From 6c7290fc5ff10f240cce4c9f885155ca5d324907 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 11:01:27 +0900 Subject: [PATCH 13/13] test(models): fail closed on invalid datetime defaults --- .../tests/test_model_timestamp_defaults.py | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_model_timestamp_defaults.py b/backend/tests/test_model_timestamp_defaults.py index c1fcaef91..3c69b4d22 100644 --- a/backend/tests/test_model_timestamp_defaults.py +++ b/backend/tests/test_model_timestamp_defaults.py @@ -11,12 +11,17 @@ import datetime +import pytest +from sqlalchemy import Column, DateTime + from db.models import Base def _datetime_default_callables(): for mapper in Base.registry.mappers: for column in mapper.columns: + if not isinstance(column.type, DateTime): + continue for kind in ("default", "onupdate"): column_default = getattr(column, kind, None) if column_default is None: @@ -43,16 +48,46 @@ def _call_with_context(default_callable): return None +def _datetime_default_issue(table, column, kind, default_callable): + value = _call_with_context(default_callable) + if not isinstance(value, datetime.datetime): + return f"{table}.{column} ({kind}) returned {type(value).__name__}" + if value.tzinfo is None: + return f"{table}.{column} ({kind}) is naive" + return None + + def test_datetime_column_defaults_are_timezone_aware(): - naive_defaults: list[str] = [] + invalid_defaults: list[str] = [] for table, column, kind, default_callable in _datetime_default_callables(): - value = _call_with_context(default_callable) - if isinstance(value, datetime.datetime) and value.tzinfo is None: - naive_defaults.append(f"{table}.{column} ({kind})") + issue = _datetime_default_issue(table, column, kind, default_callable) + if issue: + invalid_defaults.append(issue) - assert not naive_defaults, ( - "naive datetime defaults are deprecated (datetime.utcnow) and fatal " + assert not invalid_defaults, ( + "datetime defaults must return timezone-aware datetime values; " "under PYTHONWARNINGS=error; use " "lambda: datetime.datetime.now(datetime.timezone.utc): " - + ", ".join(sorted(naive_defaults)) + + ", ".join(sorted(invalid_defaults)) + ) + + +@pytest.mark.parametrize("invalid_value", ["not-a-date", 123, None]) +def test_datetime_default_guard_rejects_non_datetime_callable_results(invalid_value): + column = Column( + "event_time", + DateTime(timezone=True), + default=lambda: invalid_value, + ) + + issue = _datetime_default_issue( + "synthetic_events", + column.name, + "default", + column.default.arg, + ) + + assert issue == ( + f"synthetic_events.event_time (default) returned " + f"{type(invalid_value).__name__}" )