`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
2.4 KiB
Python
76 lines
2.4 KiB
Python
"""Timezone helpers.
|
|
|
|
Everything is stored in UTC and *reported* in the user's timezone, so there is
|
|
exactly one place that knows how to turn "now" into "which day does this count
|
|
for" — here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, date, datetime, time, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
def tz(name: str) -> ZoneInfo:
|
|
try:
|
|
return ZoneInfo(name)
|
|
except Exception: # pragma: no cover - bad tz falls back rather than crashing
|
|
return ZoneInfo("UTC")
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def ensure_utc(moment: datetime | None) -> datetime | None:
|
|
"""Attach UTC to a naive datetime.
|
|
|
|
SQLite has no timezone type, so a column declared `DateTime(timezone=True)`
|
|
comes back naive from SQLite and aware from Postgres. Everything we store is
|
|
UTC, so normalising on read keeps the arithmetic valid on both.
|
|
"""
|
|
if moment is None:
|
|
return None
|
|
return moment if moment.tzinfo is not None else moment.replace(tzinfo=UTC)
|
|
|
|
|
|
def to_local(moment: datetime, tz_name: str) -> datetime:
|
|
if moment.tzinfo is None:
|
|
moment = moment.replace(tzinfo=UTC)
|
|
return moment.astimezone(tz(tz_name))
|
|
|
|
|
|
def local_date(tz_name: str, moment: datetime | None = None) -> date:
|
|
"""The day a moment belongs to for the user (the report's unit of grouping)."""
|
|
return to_local(moment or now_utc(), tz_name).date()
|
|
|
|
|
|
def local_time(tz_name: str, moment: datetime | None = None) -> time:
|
|
return to_local(moment or now_utc(), tz_name).timetz().replace(tzinfo=None)
|
|
|
|
|
|
def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]:
|
|
"""The UTC half-open interval `[start, end)` covering a local calendar day."""
|
|
zone = tz(tz_name)
|
|
start = datetime.combine(day, time.min, tzinfo=zone)
|
|
end = start + timedelta(days=1)
|
|
return start.astimezone(UTC), end.astimezone(UTC)
|
|
|
|
|
|
def in_window(moment: time, start: time, end: time) -> bool:
|
|
"""Whether a clock time is inside a window, handling windows that wrap midnight."""
|
|
if start <= end:
|
|
return start <= moment <= end
|
|
return moment >= start or moment <= end
|
|
|
|
|
|
def humanize_minutes(total_minutes: int | float) -> str:
|
|
"""`95` -> `1h 35m`, `40` -> `40m`, `0` -> `0m`."""
|
|
minutes = round(total_minutes)
|
|
hours, rest = divmod(minutes, 60)
|
|
if hours and rest:
|
|
return f"{hours}h {rest}m"
|
|
if hours:
|
|
return f"{hours}h"
|
|
return f"{rest}m"
|