From 4b37283d228e98586a58dbbf2a8423765af2969a Mon Sep 17 00:00:00 2001 From: josephwhite <22449090+josephwhite@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:57:48 -0400 Subject: [PATCH] WebHost: Update UTC datetime usage (timezone-naive) (#4906) --- Utils.py | 11 +++++++++++ WebHostLib/autolauncher.py | 8 ++++---- WebHostLib/customserver.py | 5 ++--- WebHostLib/landing.py | 7 ++++--- WebHostLib/misc.py | 12 +++++++----- WebHostLib/models.py | 8 +++++--- WebHostLib/tracker.py | 7 ++++--- test/hosting/webhost.py | 8 +++++--- 8 files changed, 42 insertions(+), 24 deletions(-) diff --git a/Utils.py b/Utils.py index c18298559a..627235f249 100644 --- a/Utils.py +++ b/Utils.py @@ -18,6 +18,8 @@ import logging import warnings from argparse import Namespace +from datetime import datetime, timezone + from settings import Settings, get_settings from time import sleep from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard @@ -1291,6 +1293,15 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any] return isinstance(obj, typing.Iterable) +def utcnow() -> datetime: + """ + Implementation of Python's datetime.utcnow() function for use after deprecation. + Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream). + https://ponyorm.org/ponyorm-list/2014-August/000113.html + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): """ ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 96ffbe9e95..b48c6a8cbb 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -4,14 +4,14 @@ import json import logging import multiprocessing import typing -from datetime import timedelta, datetime +from datetime import timedelta from threading import Event, Thread from typing import Any from uuid import UUID from pony.orm import db_session, select, commit, PrimaryKey -from Utils import restricted_loads +from Utils import restricted_loads, utcnow from .locker import Locker, AlreadyRunningException _stop_event = Event() @@ -129,10 +129,10 @@ def autohost(config: dict): with db_session: rooms = select( room for room in Room if - room.last_activity >= datetime.utcnow() - timedelta(days=3)) + room.last_activity >= utcnow() - timedelta(days=3)) for room in rooms: # we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. - if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5): + if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5): hosters[room.id.int % len(hosters)].start_room(room.id) except AlreadyRunningException: diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index e353cf2ab2..4257c6aff3 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -172,7 +172,7 @@ class WebHostContext(Context): room.multisave = pickle.dumps(self.get_save()) # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again - room.last_activity = datetime.datetime.utcnow() + room.last_activity = Utils.utcnow() return True def get_save(self) -> dict: @@ -367,8 +367,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, with db_session: # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) - room.last_activity = datetime.datetime.utcnow() - \ - datetime.timedelta(minutes=1, seconds=room.timeout) + room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) del room tear_down_logging(room_id) logging.info(f"Shutting down room {room_id} on {name}.") diff --git a/WebHostLib/landing.py b/WebHostLib/landing.py index 14e90cc28d..f1b8de21bf 100644 --- a/WebHostLib/landing.py +++ b/WebHostLib/landing.py @@ -1,8 +1,9 @@ -from datetime import timedelta, datetime +from datetime import timedelta from flask import render_template from pony.orm import count +from Utils import utcnow from WebHostLib import app, cache from .models import Room, Seed @@ -10,6 +11,6 @@ from .models import Room, Seed @app.route('/', methods=['GET', 'POST']) @cache.cached(timeout=300) # cache has to appear under app route for caching to work def landing(): - rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7)) - seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7)) + rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7)) + seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7)) return render_template("landing.html", rooms=rooms, seeds=seeds) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index e30f1a6dd4..8d04fe984e 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -9,11 +9,12 @@ from flask import request, redirect, url_for, render_template, Response, session from pony.orm import count, commit, db_session from werkzeug.utils import secure_filename + from worlds.AutoWorld import AutoWorldRegister, World from . import app, cache from .markdown import render_markdown from .models import Seed, Room, Command, UUID, uuid4 -from Utils import title_sorted +from Utils import title_sorted, utcnow class WebWorldTheme(StrEnum): DIRT = "dirt" @@ -233,11 +234,12 @@ def host_room(room: UUID): if room is None: return abort(404) - now = datetime.datetime.utcnow() + now = utcnow() # indicate that the page should reload to get the assigned port - should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) - or room.last_activity < now - datetime.timedelta(seconds=room.timeout)) - + should_refresh = ( + (not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) + or room.last_activity < now - datetime.timedelta(seconds=room.timeout) + ) if now - room.last_activity > datetime.timedelta(minutes=1): # we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error # due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction" diff --git a/WebHostLib/models.py b/WebHostLib/models.py index 7fa54f26a0..9060bc0ca4 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -2,6 +2,8 @@ from datetime import datetime from uuid import UUID, uuid4 from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr +from Utils import utcnow + db = Database() STATE_QUEUED = 0 @@ -20,8 +22,8 @@ class Slot(db.Entity): class Room(db.Entity): id = PrimaryKey(UUID, default=uuid4) - last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page + last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True) + creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page owner = Required(UUID, index=True) commands = Set('Command') seed = Required('Seed', index=True) @@ -38,7 +40,7 @@ class Seed(db.Entity): rooms = Set(Room) multidata = Required(bytes, lazy=True) owner = Required(UUID, index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page + creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page slots = Set(Slot) spoiler = Optional(LongStr, lazy=True) meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index d1471aa658..cb40c8293f 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -10,7 +10,7 @@ from werkzeug.exceptions import abort from MultiServer import Context, get_saving_second from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType -from Utils import restricted_loads, KeyedDefaultDict +from Utils import restricted_loads, KeyedDefaultDict, utcnow from . import app, cache from .models import GameDataPackage, Room @@ -273,9 +273,10 @@ class TrackerData: Does not include players who have no activity recorded. """ last_activity: Dict[TeamPlayer, datetime.timedelta] = {} - now = datetime.datetime.utcnow() + now = utcnow() for (team, player), timestamp in self._multisave.get("client_activity_timers", []): - last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) + from_timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).replace(tzinfo=None) + last_activity[team, player] = now - from_timestamp return last_activity diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index a8e70a50c2..286ef63a55 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -6,6 +6,7 @@ import zipfile from pathlib import Path from typing import TYPE_CHECKING, Iterable, Optional, cast +from Utils import utcnow from WebHostLib import to_python if TYPE_CHECKING: @@ -133,7 +134,7 @@ def stop_room(app_client: "FlaskClient", room_id: str, timeout: Optional[float] = None, simulate_idle: bool = True) -> None: - from datetime import datetime, timedelta + from datetime import timedelta from time import sleep from pony.orm import db_session @@ -151,10 +152,11 @@ def stop_room(app_client: "FlaskClient", with db_session: room: Room = Room.get(id=room_uuid) + now = utcnow() if simulate_idle: - new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5) + new_last_activity = now - timedelta(seconds=room.timeout + 5) else: - new_last_activity = datetime.utcnow() - timedelta(days=3) + new_last_activity = now - timedelta(days=3) room.last_activity = new_last_activity address = f"localhost:{room.last_port}" if room.last_port > 0 else None if address: