mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
Python
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)
|