screen-locker/screen_locker/_temperature.py
Krzysztof kuhy Rudnicki 1d5965b9a7
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Close the four gaps that let a decided lock never reach the screen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:24:45 +02:00

166 lines
6.2 KiB
Python

"""Temperature fetching via wttr.in for the heat-skip feature.
Pure logic — no Tk imports. Always fetches from the API; never trusts
user claims about the temperature.
"""
from __future__ import annotations
from concurrent.futures import Future
from concurrent.futures import TimeoutError as _FutureTimeoutError
from dataclasses import dataclass
import http.client
import json
import logging
import threading
from typing import TYPE_CHECKING
import urllib.error
import urllib.request
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_WTTR_URL = "https://wttr.in/{city}?format=j1"
_TIMEOUT_SECONDS = 4
# urlopen's `timeout` bounds the socket connect/read, but not necessarily DNS
# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver
# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a
# second, hard wall-clock ceiling enforced independently in a worker thread.
# 5.0 is the whole budget the locker is allowed to spend deciding whether it
# is too hot to train; the socket timeout is set below it so the ordinary
# network path fails first and reports *why* rather than just "too slow".
HARD_TIMEOUT_SECONDS = 5.0
# Everything `urlopen` + `json.loads` + the subscripting below can raise.
# Spelled out rather than caught as a blind `except Exception`, which ruff
# rejects (BLE001) and which this repo has no escape hatch for.
# `http.client.HTTPException` (IncompleteRead, BadStatusLine, ...) is the
# one that used to be missing: it inherits Exception, *not* OSError, so it
# escaped this function, escaped `future.result()` (which only catches the
# timeout), and killed the locker before it could record its decision — a
# lock that silently never happened.
_FETCH_ERRORS = (
urllib.error.URLError,
TimeoutError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
KeyError,
IndexError,
ValueError,
)
@dataclass(frozen=True)
class TemperatureCheck:
"""Result of a bounded live temperature fetch."""
temp_celsius: float | None
timed_out: bool
def submit_background[T](fn: Callable[[], T]) -> Future[T]:
"""Run *fn* on a daemon thread and return a Future for its result.
Deliberately not a ``ThreadPoolExecutor``. Its worker threads are
non-daemon and joined by an interpreter-exit hook, so a genuinely stuck
DNS lookup kept the locker's process alive past ``sys.exit(0)`` — the
hard timeout below returned on time while the process did not. A daemon
thread is abandoned at exit, which is the correct behaviour for a
fire-and-forget diagnostic read.
Args:
fn: The zero-argument callable to run off the calling thread.
Returns:
A Future that resolves when *fn* returns.
"""
future: Future[T] = Future()
def _run() -> None:
try:
future.set_result(fn())
except _FETCH_ERRORS as exc:
# Never reachable via _fetch_current_temp_celsius_unbounded, which
# handles these itself; here so a future caller cannot turn a
# background thread into a future that resolves to nothing.
_logger.warning("Background fetch failed: %s", exc)
future.set_exception(exc)
threading.Thread(target=_run, daemon=True, name="temperature-fetch").start()
return future
def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
"""Return the current temperature in °C for *city*, or None on failure.
Uses wttr.in's JSON API (no API key required). Returns None on network
errors, timeouts, or unexpected response shapes so callers can fail-closed.
May block past `_TIMEOUT_SECONDS` if DNS resolution stalls — callers
should go through `fetch_current_temp_with_status` for a hard bound
instead of calling this directly.
Args:
city: The city name to look up, URL-quoted before use.
Returns:
The temperature in °C, or None if it could not be determined.
"""
url = _WTTR_URL.format(city=urllib.request.quote(city, safe=""))
try:
with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as resp:
data = json.loads(resp.read())
temp_str = data["current_condition"][0]["temp_C"]
return float(temp_str)
except _FETCH_ERRORS as exc:
_logger.warning(
"Temperature fetch failed (%s: %s) — the caller must fail closed "
"and lock rather than treat this as 'not hot'",
type(exc).__name__,
exc,
)
return None
def fetch_current_temp_with_status(
city: str, *, hard_timeout: float = HARD_TIMEOUT_SECONDS
) -> TemperatureCheck:
"""Fetch °C for *city*, bounded to *hard_timeout* seconds wall-clock.
Runs the fetch on a daemon thread and stops waiting on it after
*hard_timeout* regardless of what the network call is doing internally
(DNS, connect, read). The thread itself can't be killed and may keep
running in the background after we give up on it — acceptable for a
fire-and-forget diagnostic read, and harmless at exit because it is a
daemon. Distinguishes "timed out" from other failures (network/parse
errors) so callers can report which happened.
Args:
city: The city name to look up.
hard_timeout: Wall-clock ceiling in seconds.
Returns:
The temperature and whether the wall-clock ceiling was hit.
"""
future = submit_background(lambda: _fetch_current_temp_celsius_unbounded(city))
try:
temp = future.result(timeout=hard_timeout)
except _FutureTimeoutError:
_logger.warning(
"Temperature fetch exceeded hard timeout of %.1fs — the caller "
"must fail closed and lock rather than treat this as 'not hot'",
hard_timeout,
)
return TemperatureCheck(temp_celsius=None, timed_out=True)
except _FETCH_ERRORS as exc:
_logger.warning(
"Temperature fetch raised past its own handler (%s: %s) — "
"reporting failure so the caller fails closed and locks",
type(exc).__name__,
exc,
)
return TemperatureCheck(temp_celsius=None, timed_out=False)
return TemperatureCheck(temp_celsius=temp, timed_out=False)