From 29c651cc20635bab41a291d870922518479080b5 Mon Sep 17 00:00:00 2001 From: Jesse Bryce Date: Sat, 10 Oct 2026 17:28:56 +0100 Subject: [PATCH 1/2] fix: accept colon-less +0100 last_updated in is_running() on Python 3.9/3.10 record_status() writes last_updated with TIME_FORMAT (%Y-%m-%dT%H:%M:%S%z); strftime('%z') never emits a colon, and datetime.fromisoformat() only accepts the colon-less form from Python 3.11, so is_running() parsed the attribute as invalid on 3.9/3.10 and reported a false unhealthy status. Parse with utils.str2time() first (its strptime fallback handles +0100 on all versions), falling back to datetime.fromisoformat() on ValueError so legacy naive last_updated values still reach the existing naive-now branch (#5181 review: keep the naive tolerance added in b8cb0350). load_plan() is left untouched: its timestamp is written via .isoformat() (colon form), so it is not affected. Adds fresh-timestamp coverage for +0100, +01:00 and naive last_updated to the existing is_running() tests. --- apps/predbat/predbat.py | 11 +++++++++-- apps/predbat/tests/test_web_functions.py | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 93508ee50..94abf2076 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -77,6 +77,7 @@ from config import APPS_SCHEMA, CONFIG_ITEMS import debug_history from utils import ( + str2time, minutes_since_yesterday, minutes_since_midnight, dp1, @@ -1984,8 +1985,14 @@ def is_running(self): return False try: - predbat_last_updated = datetime.fromisoformat(predbat_last_updated) - except ValueError: + try: + predbat_last_updated = str2time(predbat_last_updated) + except ValueError: + # str2time() requires a timezone offset, so a legacy naive last_updated (written + # before record_status() went timezone-aware) lands here - fall back to the + # naive-tolerant parser and let the tzinfo check below handle it (#5181). + predbat_last_updated = datetime.fromisoformat(predbat_last_updated) + except (ValueError, TypeError): return False # Check if the last updated time is within the last 15 minutes. An install upgraded diff --git a/apps/predbat/tests/test_web_functions.py b/apps/predbat/tests/test_web_functions.py index 45c0a8392..12f48defc 100644 --- a/apps/predbat/tests/test_web_functions.py +++ b/apps/predbat/tests/test_web_functions.py @@ -10,6 +10,7 @@ import asyncio import os +from datetime import datetime, timezone from web import WebInterface, is_data_numerical from web_helper import get_plan_renderer_js @@ -221,6 +222,29 @@ def set_exporting(on): print(f" ERROR: expected is_running() False for a stale (2026-07-10) aware last_updated, got: {result}") failed += 1 + # A fresh last_updated must report running in every format the attribute can hold: + # the colon-less +0100 that record_status() writes (strftime('%z') never emits a colon, + # which datetime.fromisoformat() only accepts from Python 3.11 - issue #5181), the + # colon form, and the legacy naive form. Naive must still reach the naive-now branch + # rather than being rejected as unparseable. + now_local = datetime.now(timezone.utc).astimezone() + fresh_formats = { + "colon-less offset (record_status writer format)": now_local.strftime("%Y-%m-%dT%H:%M:%S%z"), + "colon offset": now_local.isoformat(), + "naive legacy": str(datetime.now()), + } + for label, stamp in fresh_formats.items(): + set_entity(my_predbat, status_entity, state="Idle", error=False, last_updated=stamp) + try: + result = my_predbat.is_running() + except (TypeError, ValueError) as e: + print(f" ERROR: is_running() raised on a fresh {label} ({stamp}): {e}") + failed += 1 + else: + if result is not True: + print(f" ERROR: expected is_running() True for a fresh {label} ({stamp}), got: {result}") + failed += 1 + my_predbat.dashboard_index = original_dashboard_index my_predbat.stop_thread = original_stop_thread my_predbat.fatal_error = original_fatal_error From 2f2ec6079f8b1728dddc5b70fc643b09a48f13ce Mon Sep 17 00:00:00 2001 From: Jesse Bryce Date: Sat, 10 Oct 2026 19:20:00 +0100 Subject: [PATCH 2/2] test: assert is_running() parses last_updated with str2time() The Python that CI runs accepts +0100, +01:00 and naive strings through datetime.fromisoformat() alone, so the fresh-timestamp cases passed with or without the fix. Spy on predbat.str2time and require each value to be handed to it, so reverting the fix fails the test on every Python version. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/tests/test_web_functions.py | 39 ++++++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/predbat/tests/test_web_functions.py b/apps/predbat/tests/test_web_functions.py index 12f48defc..3cf3c6194 100644 --- a/apps/predbat/tests/test_web_functions.py +++ b/apps/predbat/tests/test_web_functions.py @@ -12,6 +12,7 @@ import os from datetime import datetime, timezone +import predbat as predbat_module from web import WebInterface, is_data_numerical from web_helper import get_plan_renderer_js @@ -233,17 +234,35 @@ def set_exporting(on): "colon offset": now_local.isoformat(), "naive legacy": str(datetime.now()), } - for label, stamp in fresh_formats.items(): - set_entity(my_predbat, status_entity, state="Idle", error=False, last_updated=stamp) - try: - result = my_predbat.is_running() - except (TypeError, ValueError) as e: - print(f" ERROR: is_running() raised on a fresh {label} ({stamp}): {e}") - failed += 1 - else: - if result is not True: - print(f" ERROR: expected is_running() True for a fresh {label} ({stamp}), got: {result}") + # The Python that CI runs accepts all three formats through datetime.fromisoformat() alone, + # so also check that is_running() hands the value to str2time() - without that, reverting + # the fix would leave these cases passing everywhere except Python 3.9/3.10. + original_str2time = predbat_module.str2time + str2time_calls = [] + + def str2time_spy(time_str): + str2time_calls.append(time_str) + return original_str2time(time_str) + + predbat_module.str2time = str2time_spy + try: + for label, stamp in fresh_formats.items(): + set_entity(my_predbat, status_entity, state="Idle", error=False, last_updated=stamp) + del str2time_calls[:] + try: + result = my_predbat.is_running() + except (TypeError, ValueError) as e: + print(f" ERROR: is_running() raised on a fresh {label} ({stamp}): {e}") failed += 1 + else: + if result is not True: + print(f" ERROR: expected is_running() True for a fresh {label} ({stamp}), got: {result}") + failed += 1 + if str2time_calls != [stamp]: + print(f" ERROR: expected is_running() to parse a fresh {label} ({stamp}) with str2time(), got calls: {str2time_calls}") + failed += 1 + finally: + predbat_module.str2time = original_str2time my_predbat.dashboard_index = original_dashboard_index my_predbat.stop_thread = original_stop_thread