Skip to content

Commit

Permalink
Use an NTP server to fetch the current time
Browse files Browse the repository at this point in the history
Fixes #235. Instead of relying on the computer's local time (which may
be off), the time is fetched from an NTP server when possible. This
ensures the script performs check-ins at the correct times even when the
local time is incorrect.
  • Loading branch information
jdholtz committed Apr 20, 2024
1 parent f37cba3 commit 7329b81
Show file tree
Hide file tree
Showing 11 changed files with 97 additions and 26 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
When upgrading to a new version, make sure to follow the directions under the "Upgrading" header of the corresponding version.
If there is no "Upgrading" header for that version, no post-upgrade actions need to be performed.


## Upcoming
### New Features
- Times are now fetched from an NTP server when possible ([#235](https://github.com/jdholtz/auto-southwest-check-in/issues/235))
- This mitigates issues with the time being off on computers running the script, which may cause failed check-ins

### Upgrading
- Upgrade the dependencies to the latest versions by running `pip install -r requirements.txt`
- [ntplib](https://pypi.org/project/ntplib/) is a now a dependency


## 7.4 (2024-04-14)
### New Features
- A [development container](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers)
Expand Down
6 changes: 3 additions & 3 deletions lib/checkin_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from .flight import Flight
from .log import get_logger
from .utils import RequestError, make_request
from .utils import RequestError, get_current_time, make_request

if TYPE_CHECKING:
from .checkin_scheduler import CheckInScheduler
Expand Down Expand Up @@ -82,7 +82,7 @@ def _set_check_in(self) -> None:
pass

def _wait_for_check_in(self, checkin_time: datetime) -> None:
current_time = datetime.utcnow()
current_time = get_current_time()
if checkin_time <= current_time:
logger.debug("Check-in time has passed. Going straight to check-in")
return
Expand All @@ -104,7 +104,7 @@ def _wait_for_check_in(self, checkin_time: datetime) -> None:

logger.debug("Lock released")

current_time = datetime.utcnow()
current_time = get_current_time()
sleep_time = (checkin_time - current_time).total_seconds()
logger.debug("Sleeping until check-in: %d seconds...", sleep_time)
time.sleep(sleep_time)
Expand Down
4 changes: 2 additions & 2 deletions lib/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import json
import os
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict

Expand Down Expand Up @@ -71,7 +71,7 @@ def _convert_to_utc(self, flight_date: str, airport_timezone: Any) -> datetime:
flight_date = datetime.strptime(flight_date, "%Y-%m-%d %H:%M")
self._local_departure_time = airport_timezone.localize(flight_date)

utc_time = self._local_departure_time.astimezone(pytz.utc).replace(tzinfo=None)
utc_time = self._local_departure_time.astimezone(timezone.utc).replace(tzinfo=None)
return utc_time

def _get_flight_number(self, flights: JSON) -> str:
Expand Down
8 changes: 4 additions & 4 deletions lib/reservation_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .fare_checker import FareChecker
from .log import get_logger
from .notification_handler import NotificationHandler
from .utils import FlightChangeError, LoginError, RequestError
from .utils import FlightChangeError, LoginError, RequestError, get_current_time
from .webdriver import WebDriver

TOO_MANY_REQUESTS_CODE = 429
Expand Down Expand Up @@ -57,7 +57,7 @@ def _monitor(self) -> None:
reservation = {"confirmationNumber": self.config.confirmation_number}

while True:
time_before = datetime.utcnow()
time_before = get_current_time()

logger.debug("Acquiring lock...")
with self.lock:
Expand Down Expand Up @@ -124,7 +124,7 @@ def _smart_sleep(self, previous_time: datetime) -> None:
Account for the time it took to do recurring tasks so the sleep interval
is the exact time provided in the configuration file.
"""
current_time = datetime.utcnow()
current_time = get_current_time()
time_taken = (current_time - previous_time).total_seconds()
sleep_time = self.config.retrieval_interval - time_taken
logger.debug("Sleeping for %d seconds", sleep_time)
Expand Down Expand Up @@ -163,7 +163,7 @@ def _monitor(self) -> None:
Check for newly booked reservations for the account every X hours (retrieval interval).
"""
while True:
time_before = datetime.utcnow()
time_before = get_current_time()

logger.debug("Acquiring lock...")
with self.lock:
Expand Down
22 changes: 22 additions & 0 deletions lib/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import json
import socket
import time
from datetime import datetime, timezone
from enum import IntEnum
from typing import Any, Dict, Union

import ntplib
import requests

from .log import get_logger
Expand Down Expand Up @@ -51,6 +54,25 @@ def make_request(method: str, site: str, headers: JSON, info: JSON, max_attempts
raise RequestError(error_msg, response_body)


def get_current_time() -> datetime:
"""
Fetch the current time from an NTP server. Times are sometimes off on computers running the
script and since check-ins rely on exact times, this ensures check-ins are done at the correct
time. Falls back to local time if the request to the NTP server fails.
Times are returned in UTC.
"""
c = ntplib.NTPClient()

try:
response = c.request("us.pool.ntp.org", version=3)
except socket.gaierror:
logger.debug("Error requesting time from NTP server. Using local time")
return datetime.utcnow()

return datetime.fromtimestamp(response.tx_time, timezone.utc).replace(tzinfo=None)


# Make a custom exception when a request fails
class RequestError(Exception):
def __init__(self, message: str, response_body: str = "") -> None:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
apprise==1.7.6
ntplib==0.4.0
pytz==2024.1 # Remove when this script only supports Python 3.9+
requests==2.31.0
seleniumbase==4.25.4
12 changes: 7 additions & 5 deletions tests/integration/test_check_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ def test_check_in(
handler: CheckInHandler,
same_day_flight: bool,
) -> None:
mock_datetime = mocker.patch("lib.checkin_handler.datetime")
mock_datetime.utcnow.side_effect = [
datetime(2021, 12, 5, 13, 40),
datetime(2021, 12, 5, 14, 20),
]
mocker.patch(
"lib.checkin_handler.get_current_time",
side_effect=[
datetime(2021, 12, 5, 13, 40),
datetime(2021, 12, 5, 14, 20),
],
)
mock_sleep = mocker.patch("time.sleep")

handler.first_name = "Garry"
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/test_monitoring_and_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import copy
import json
from datetime import datetime
from multiprocessing import Lock
from unittest import mock

Expand Down Expand Up @@ -59,6 +60,7 @@ def test_flight_is_scheduled_checks_in_and_departs(
tz_data = {"LAX": "America/Los_Angeles"}

mocker.patch("pathlib.Path.read_text", return_value=json.dumps(tz_data))
mocker.patch("lib.reservation_monitor.get_current_time", return_value=datetime(1999, 12, 31))
mock_process = mocker.patch("lib.checkin_handler.Process").return_value
mock_new_flights_notification = mocker.patch(
"lib.notification_handler.NotificationHandler.new_flights"
Expand Down Expand Up @@ -135,6 +137,7 @@ def test_account_schedules_new_flights(requests_mock: RequestMocker, mocker: Moc
tz_data = {"LAX": "America/Los_Angeles", "SYD": "Australia/Sydney"}
mocker.patch("pathlib.Path.read_text", return_value=json.dumps(tz_data))

mocker.patch("lib.reservation_monitor.get_current_time", return_value=datetime(1999, 12, 31))
mocker.patch("lib.webdriver.seleniumbase_actions.wait_for_element_not_visible")
mock_process = mocker.patch("lib.checkin_handler.Process").return_value
# Raise a StopIteration to prevent an infinite loop
Expand Down
22 changes: 14 additions & 8 deletions tests/unit/test_checkin_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,19 @@ def test_wait_for_check_in_exits_immediately_if_checkin_time_has_passed(
self, mocker: MockerFixture
) -> None:
mock_sleep = mocker.patch("time.sleep")
self.handler._wait_for_check_in(datetime(1999, 12, 31))
mocker.patch(
"lib.checkin_handler.get_current_time", return_value=datetime(1999, 12, 31, 18, 30)
)
self.handler._wait_for_check_in(datetime(1999, 12, 31, 18))
mock_sleep.assert_not_called()

def test_wait_for_check_in_sleeps_once_when_check_in_is_less_than_thirty_minutes_away(
self, mocker: MockerFixture
) -> None:
mock_sleep = mocker.patch("time.sleep")
mock_datetime = mocker.patch("lib.checkin_handler.datetime")
mock_datetime.utcnow.return_value = datetime(1999, 12, 31, 18, 29, 59)
mocker.patch(
"lib.checkin_handler.get_current_time", return_value=datetime(1999, 12, 31, 18, 29, 59)
)

self.handler._wait_for_check_in(datetime(1999, 12, 31, 18, 59, 59))

Expand All @@ -102,11 +106,13 @@ def test_wait_for_check_in_refreshes_headers_thirty_minutes_before_check_in(
) -> None:
mock_sleep = mocker.patch("time.sleep")
mock_refresh_headers = self.handler.checkin_scheduler.refresh_headers
mock_datetime = mocker.patch("lib.checkin_handler.datetime")
mock_datetime.utcnow.side_effect = [
datetime(1999, 12, 31, 18, 29, 59),
datetime(1999, 12, 31, 23, 19, 59),
]
mocker.patch(
"lib.checkin_handler.get_current_time",
side_effect=[
datetime(1999, 12, 31, 18, 29, 59),
datetime(1999, 12, 31, 23, 19, 59),
],
)

self.handler._wait_for_check_in(datetime(1999, 12, 31, 23, 49, 59))

Expand Down
15 changes: 11 additions & 4 deletions tests/unit/test_reservation_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ def mock_lock(mocker: MockerFixture) -> None:
)
class TestReservationMonitor:
@pytest.fixture(autouse=True)
def _set_up_monitor(self, mock_lock: mock.Mock) -> None:
def _set_up_monitor(self, mock_lock: mock.Mock, mocker: MockerFixture) -> None:
# pylint: disable=attribute-defined-outside-init
self.monitor = ReservationMonitor(ReservationConfig(), mock_lock)
mocker.patch(
"lib.reservation_monitor.get_current_time", return_value=datetime(1999, 12, 31)
)

def test_start_starts_a_process(self, mocker: MockerFixture) -> None:
mock_process_start = mocker.patch.object(multiprocessing.Process, "start")
Expand Down Expand Up @@ -159,8 +162,9 @@ def test_check_flight_fares_catches_error_when_checking_fares(

def test_smart_sleep_sleeps_for_correct_time(self, mocker: MockerFixture) -> None:
mock_sleep = mocker.patch("time.sleep")
mock_datetime = mocker.patch("lib.reservation_monitor.datetime")
mock_datetime.utcnow.return_value = datetime(1999, 12, 31)
mocker.patch(
"lib.reservation_monitor.get_current_time", return_value=datetime(1999, 12, 31)
)

self.monitor.config.retrieval_interval = 24 * 60 * 60
self.monitor._smart_sleep(datetime(1999, 12, 30, 12))
Expand All @@ -187,9 +191,12 @@ def test_stop_monitoring_stops_checkins(self, mocker: MockerFixture) -> None:
)
class TestAccountMonitor:
@pytest.fixture(autouse=True)
def _set_up_monitor(self, mock_lock: mock.Mock) -> None:
def _set_up_monitor(self, mock_lock: mock.Mock, mocker: MockerFixture) -> None:
# pylint: disable=attribute-defined-outside-init
self.monitor = AccountMonitor(AccountConfig(), mock_lock)
mocker.patch(
"lib.reservation_monitor.get_current_time", return_value=datetime(1999, 12, 31)
)

def test_monitor_monitors_the_account_continuously(self, mocker: MockerFixture) -> None:
# Since the monitor function runs in an infinite loop, throw an Exception
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import json
import socket
from datetime import datetime
from typing import Any

import ntplib
import pytest
from pytest_mock import MockerFixture
from requests_mock.mocker import Mocker as RequestMocker
Expand Down Expand Up @@ -75,6 +78,22 @@ def test_make_request_handles_malformed_URLs(requests_mock: RequestMocker) -> No
assert mock_post.last_request.url == utils.BASE_URL + "test/test2"


def test_get_current_time_returns_a_datetime_from_ntp_server(mocker: MockerFixture) -> None:
ntp_stats = ntplib.NTPStats()
ntp_stats.tx_timestamp = 3155673599
mocker.patch("ntplib.NTPClient.request", return_value=ntp_stats)

assert utils.get_current_time() == datetime(1999, 12, 31, 23, 59, 59)


def test_get_current_time_returns_local_datetime_on_failed_request(mocker: MockerFixture) -> None:
mocker.patch("ntplib.NTPClient.request", side_effect=socket.gaierror)
mock_datetime = mocker.patch("lib.utils.datetime")
mock_datetime.utcnow.return_value = datetime(1999, 12, 31, 18, 59, 59)

assert utils.get_current_time() == datetime(1999, 12, 31, 18, 59, 59)


@pytest.mark.parametrize(
"value, expected",
[
Expand Down

0 comments on commit 7329b81

Please sign in to comment.