Skip to content

Commit 39a3d38

Browse files
committed
Fix Python 3.10 compatibility
1 parent a7abd45 commit 39a3d38

5 files changed

Lines changed: 38 additions & 19 deletions

File tree

‎src/pg_stand/cli.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import sys
99
import time
1010
from collections.abc import Sequence
11-
from datetime import UTC, datetime, timedelta
11+
from datetime import datetime, timedelta, timezone
1212
from typing import Any
1313

1414
import yaml
@@ -39,7 +39,7 @@ def _emit_stream(value: Any, *, as_json: bool) -> None:
3939

4040

4141
def _moment(value: str, *, now: datetime | None = None) -> datetime:
42-
reference = now or datetime.now(UTC)
42+
reference = now or datetime.now(timezone.utc)
4343
match = _DURATION_RE.fullmatch(value.strip().lower())
4444
if match:
4545
factors = {"s": 1, "m": 60, "h": 3600, "d": 86400}
@@ -52,8 +52,8 @@ def _moment(value: str, *, now: datetime | None = None) -> datetime:
5252
f"invalid time {value!r}; use an ISO-8601 timestamp or duration such as 30m"
5353
) from exc
5454
if parsed.tzinfo is None:
55-
parsed = parsed.replace(tzinfo=UTC)
56-
return parsed.astimezone(UTC)
55+
parsed = parsed.replace(tzinfo=timezone.utc)
56+
return parsed.astimezone(timezone.utc)
5757

5858

5959
def _health_options(args: argparse.Namespace) -> dict[str, Any]:
@@ -227,7 +227,7 @@ def _execute(args: argparse.Namespace, config: StandConfig) -> int:
227227
elif args.format == "raw" and args.node != "all" and not has_filters and not args.json:
228228
manager.logs(args.node, tail=args.tail)
229229
else:
230-
now = datetime.now(UTC)
230+
now = datetime.now(timezone.utc)
231231
records = manager.log_records(
232232
args.node,
233233
tail=args.tail,
@@ -244,7 +244,7 @@ def _execute(args: argparse.Namespace, config: StandConfig) -> int:
244244
elif args.command == "events":
245245
if args.follow and args.until:
246246
raise DockerRuntimeError("events --follow cannot be combined with --until")
247-
now = datetime.now(UTC)
247+
now = datetime.now(timezone.utc)
248248
since = int(_moment(args.since, now=now).timestamp())
249249
until = (
250250
None

‎src/pg_stand/runtime_nodes.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import shutil
99
import subprocess
1010
from dataclasses import asdict
11-
from datetime import UTC, datetime
11+
from datetime import datetime, timezone
1212
from typing import Any
1313
from urllib.parse import quote, urlencode
1414

@@ -414,13 +414,17 @@ def _log_timestamp(value: str) -> datetime | None:
414414
text = value.strip()
415415
if text.endswith(" UTC"):
416416
text = text[:-4] + "+00:00"
417+
elif len(text) >= 3 and text[-3] in "+-" and text[-2:].isdigit():
418+
# PostgreSQL can emit a whole-hour offset such as +00. Python 3.10
419+
# requires the equivalent ISO 8601 form with explicit minutes.
420+
text += ":00"
417421
try:
418422
parsed = datetime.fromisoformat(text.replace(" ", "T", 1))
419423
except ValueError:
420424
return None
421425
if parsed.tzinfo is None:
422-
return parsed.replace(tzinfo=UTC)
423-
return parsed.astimezone(UTC)
426+
return parsed.replace(tzinfo=timezone.utc)
427+
return parsed.astimezone(timezone.utc)
424428

425429
def log_records(
426430
self,
@@ -478,7 +482,7 @@ def log_records(
478482
).casefold()
479483
if needle not in haystack:
480484
continue
481-
record["_timestamp"] = timestamp or datetime.min.replace(tzinfo=UTC)
485+
record["_timestamp"] = timestamp or datetime.min.replace(tzinfo=timezone.utc)
482486
filtered.append(record)
483487
filtered.sort(key=lambda item: (item["_timestamp"], item["node"], item["session_id"]))
484488
if tail >= 0:

‎src/pg_stand/runtime_observe.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import time
66
from dataclasses import asdict
7-
from datetime import UTC, datetime
7+
from datetime import datetime, timezone
88
from typing import Any
99

1010
from pg_stand.config import NodeConfig
@@ -615,7 +615,7 @@ def health(
615615
"project": self.config.metadata.name,
616616
"status": level,
617617
"exit_code": {"healthy": 0, "degraded": 1, "unavailable": 2}[level],
618-
"checked_at": datetime.now(UTC).isoformat(),
618+
"checked_at": datetime.now(timezone.utc).isoformat(),
619619
"checks": checks,
620620
}
621621

@@ -721,7 +721,7 @@ def docker_events(
721721
if name and not name.endswith(MANAGED_RESOURCE_SUFFIX):
722722
continue
723723
yield {
724-
"time": datetime.fromtimestamp(int(event.get("time", 0)), UTC).isoformat(),
724+
"time": datetime.fromtimestamp(int(event.get("time", 0)), timezone.utc).isoformat(),
725725
"type": event.get("Type"),
726726
"action": event.get("Action") or event.get("status"),
727727
"id": event.get("id") or actor.get("ID"),
@@ -735,7 +735,7 @@ def docker_events(
735735

736736
def overview(self) -> dict[str, Any]:
737737
result: dict[str, Any] = {
738-
"observed_at": datetime.now(UTC).isoformat(),
738+
"observed_at": datetime.now(timezone.utc).isoformat(),
739739
"status": self.status(),
740740
"resources": self.resources(),
741741
}

‎tests/test_cli.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,11 @@ def test_new_observation_and_management_command_shapes() -> None:
160160

161161

162162
def test_relative_and_iso_times_are_supported() -> None:
163-
from datetime import UTC, datetime
163+
from datetime import datetime, timezone
164164

165-
now = datetime(2026, 7, 17, 12, 0, tzinfo=UTC)
165+
now = datetime(2026, 7, 17, 12, 0, tzinfo=timezone.utc)
166166

167-
assert _moment("30m", now=now) == datetime(2026, 7, 17, 11, 30, tzinfo=UTC)
167+
assert _moment("30m", now=now) == datetime(2026, 7, 17, 11, 30, tzinfo=timezone.utc)
168168
assert _moment("2026-07-17T14:00:00+02:00") == now
169169

170170

‎tests/test_runtime.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
from dataclasses import replace
5-
from datetime import UTC, datetime
5+
from datetime import datetime, timezone
66
from pathlib import Path
77
from types import SimpleNamespace
88
from unittest.mock import MagicMock, call, patch
@@ -894,6 +894,21 @@ def test_pgbouncer_has_separate_pool_modes_and_certificate_only_tls() -> None:
894894
assert "hostssl all all all cert map=pg_stand_tls" in script
895895

896896

897+
@pytest.mark.parametrize(
898+
("value", "expected"),
899+
[
900+
("2026-07-17 12:01:00+00", "2026-07-17T12:01:00+00:00"),
901+
("2026-07-17 15:01:00+03", "2026-07-17T12:01:00+00:00"),
902+
("2026-07-17 12:01:00 UTC", "2026-07-17T12:01:00+00:00"),
903+
],
904+
)
905+
def test_log_timestamp_accepts_postgresql_timezone_formats(value: str, expected: str) -> None:
906+
parsed = StandManager._log_timestamp(value)
907+
908+
assert parsed is not None
909+
assert parsed.isoformat() == expected
910+
911+
897912
def test_log_records_merge_sort_and_filter() -> None:
898913
config = load_config(ASSET_ROOT / "configs" / "primary-standby-sync.yaml", environment={})
899914
manager = StandManager(config, client=object())
@@ -931,7 +946,7 @@ def test_log_records_merge_sort_and_filter() -> None:
931946
with patch.object(manager, "_csv_log_records", side_effect=lambda node: rows[node]):
932947
result = manager.log_records(
933948
"all",
934-
since=datetime(2026, 7, 17, 12, 0, 30, tzinfo=UTC),
949+
since=datetime(2026, 7, 17, 12, 0, 30, tzinfo=timezone.utc),
935950
severity="error",
936951
contains="target",
937952
)

0 commit comments

Comments
 (0)