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 jdholtz#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 authored and dmytrokoren committed Jun 11, 2024
1 parent 9b8cac5 commit 7739d48
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,9 +1,12 @@
import json
import socket
import time
import random
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 @@ -57,6 +60,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.27.5
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 7739d48

Please sign in to comment.