diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b76ec47..d216be8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,12 +25,22 @@ jobs:
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov pytest-html flaky
- # These tests call flightradar24 directly; Cloudflare blocks GitHub-hosted
- # runner IPs regardless of TLS impersonation, so failures here are expected
- # and don't reflect code health. Kept informational rather than a hard gate.
- - name: Run tests
+ # Offline unit tests (test_unit.py) exercise pure parsing/filtering/state
+ # logic with no network access, so they are a real gate: if these break,
+ # fail the build.
+ - name: Run offline unit tests
+ run: pytest pyflightdata/test_unit.py
+ # The remaining tests call flightradar24 directly; Cloudflare blocks
+ # GitHub-hosted runner IPs regardless of TLS impersonation, so failures
+ # here are expected and don't reflect code health. Kept informational.
+ # Authenticated tests read FR24_EMAIL/FR24_PASSWORD if those repo secrets
+ # exist, and skip themselves otherwise.
+ - name: Run integration tests (informational)
continue-on-error: true
- run: pytest pyflightdata
+ env:
+ FR24_EMAIL: ${{ secrets.FR24_EMAIL }}
+ FR24_PASSWORD: ${{ secrets.FR24_PASSWORD }}
+ run: pytest pyflightdata --ignore=pyflightdata/test_unit.py
publish:
if: github.event_name == 'release' && github.event.action == 'published'
diff --git a/.gitignore b/.gitignore
index d2de837..e695083 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,10 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
+# pytest-html report generated by the forced --html addopts in pytest.ini
+/test_report.html
+/assets/
+.pytest_cache/
# Translations
*.mo
diff --git a/pyflightdata/common.py b/pyflightdata/common.py
index 99a0d14..08eef79 100644
--- a/pyflightdata/common.py
+++ b/pyflightdata/common.py
@@ -21,7 +21,6 @@
# SOFTWARE.
import json
-import sys
import time
from bs4 import BeautifulSoup
@@ -54,8 +53,6 @@ def _byteify(self, data, ignore_dicts=False):
# if this is a unicode string, return its string representation
if isinstance(data, str):
return self.encode_and_get(data)
- if sys.version_info[0] < 3 and isinstance(data, unicode):
- return self.encode_and_get(data)
# if this is a list of values, return list of byteified values
if isinstance(data, list):
return [self._byteify(item, ignore_dicts=True) for item in data]
@@ -159,7 +156,4 @@ def get_raw_data_json(self, url, path):
pass
def encode_and_get(self, string):
- if sys.version_info[0] < 3:
- return string.encode('unicode-escape').replace('\\xa0', ' ')
- else:
- return string.encode('unicode-escape').replace(b'\\xa0', b' ').decode('utf-8')
+ return string.encode('unicode-escape').replace(b'\\xa0', b' ').decode('utf-8')
diff --git a/pyflightdata/common_fr24.py b/pyflightdata/common_fr24.py
index 6e31597..b90ca12 100644
--- a/pyflightdata/common_fr24.py
+++ b/pyflightdata/common_fr24.py
@@ -255,13 +255,21 @@ def get_raw_airline_fleet_data(self, url):
def process_raw_airline_fleet_data(self, data, authenticated):
result = []
for parent in data:
- record = {}
cells = parent.find_all('td')
- record['reg'] = cells[0].find('a').text.strip()
+ # Rows without at least the reg + type columns are layout/header
+ # artifacts from flightradar24's markup, not fleet entries; skip them
+ # instead of raising IndexError when the site tweaks its HTML.
+ if len(cells) < 2:
+ continue
+ reg_link = cells[0].find('a')
+ if reg_link is None:
+ continue
+ record = {}
+ record['reg'] = reg_link.text.strip()
record['type'] = cells[1].text.strip()
record['msn'] = 'requires user login'
record['age'] = 'requires user login'
- if authenticated:
+ if authenticated and len(cells) >= 4:
record['msn'] = cells[2].text.strip()
record['age'] = cells[3].text.strip()
result.append(record)
diff --git a/pyflightdata/conftest.py b/pyflightdata/conftest.py
new file mode 100644
index 0000000..85527b3
--- /dev/null
+++ b/pyflightdata/conftest.py
@@ -0,0 +1,79 @@
+# MIT License
+#
+# Copyright (c) 2020 Hari Allamraju
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Shared pytest fixtures.
+
+The authenticated online tests need real flightradar24 credentials. They are
+read, in order of precedence, from:
+
+1. the ``FR24_EMAIL`` / ``FR24_PASSWORD`` environment variables (use these in
+ CI secrets), or
+2. a local ``.env`` file at the repository root with ``email=`` / ``password=``
+ lines (gitignored, handy for local runs).
+
+When no credentials are available the ``credentials`` fixture skips the test, so
+the suite stays green on CI runners that have no secret configured.
+"""
+
+import os
+
+import pytest
+
+
+def _read_env_file(path):
+ values = {}
+ try:
+ with open(path) as handle:
+ for line in handle:
+ line = line.strip()
+ if not line or line.startswith('#') or '=' not in line:
+ continue
+ key, _, value = line.partition('=')
+ values[key.strip()] = value.strip().strip('"').strip("'")
+ except OSError:
+ pass
+ return values
+
+
+def load_credentials():
+ """Return ``(email, password)`` or ``(None, None)`` if not configured."""
+ email = os.environ.get('FR24_EMAIL')
+ password = os.environ.get('FR24_PASSWORD')
+ if email and password:
+ return email, password
+ env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')
+ values = _read_env_file(env_path)
+ email = email or values.get('FR24_EMAIL') or values.get('email')
+ password = password or values.get('FR24_PASSWORD') or values.get('password')
+ if email and password:
+ return email, password
+ return None, None
+
+
+@pytest.fixture(scope='session')
+def credentials():
+ email, password = load_credentials()
+ if not (email and password):
+ pytest.skip(
+ 'no flightradar24 credentials; set FR24_EMAIL/FR24_PASSWORD or a '
+ 'local .env with email=/password=')
+ return email, password
diff --git a/pyflightdata/flightdata.py b/pyflightdata/flightdata.py
index d7fef74..5eb8fc7 100644
--- a/pyflightdata/flightdata.py
+++ b/pyflightdata/flightdata.py
@@ -661,25 +661,41 @@ def login(self, email, password):
f=FlightData()
f.login(myemail,mypassword)
+ Returns:
+ bool: True if the login succeeded and an auth token was obtained,
+ False otherwise (bad credentials, network error, or an unexpected
+ response shape from flightradar24).
+
"""
- response = FlightData.session.post(
- url=LOGIN_URL,
- data={
- 'email': email,
- 'password': password,
- 'remember': 'true',
- 'type': 'web'
- },
- headers={
- 'Origin': 'https://www.flightradar24.com',
- 'Referer': 'https://www.flightradar24.com'
- }
- )
- response = self._fr24.json_loads_byteified(
- response.content) if response.status_code == 200 else None
- if response:
- token = response['userData']['subscriptionKey']
- self.AUTH_TOKEN = token
+ try:
+ response = FlightData.session.post(
+ url=LOGIN_URL,
+ data={
+ 'email': email,
+ 'password': password,
+ 'remember': 'true',
+ 'type': 'web'
+ },
+ headers={
+ 'Origin': 'https://www.flightradar24.com',
+ 'Referer': 'https://www.flightradar24.com'
+ }
+ )
+ except Exception:
+ return False
+ if response.status_code != 200:
+ return False
+ try:
+ payload = self._fr24.json_loads_byteified(response.content)
+ token = payload['userData']['subscriptionKey']
+ except (ValueError, KeyError, TypeError):
+ # ValueError: body was not JSON; KeyError/TypeError: JSON shape
+ # changed and the token is no longer where we expect it.
+ return False
+ if not token:
+ return False
+ self.AUTH_TOKEN = token
+ return True
def logout(self):
"""Logout from the flightradar24 session.
diff --git a/pyflightdata/pytest.ini b/pyflightdata/pytest.ini
index 54f2f32..1c0a67e 100644
--- a/pyflightdata/pytest.ini
+++ b/pyflightdata/pytest.ini
@@ -1,3 +1,6 @@
[pytest]
addopts = --html=test_report.html
-python_files = *.py
\ No newline at end of file
+python_files = test_*.py
+markers =
+ online: test hits the live flightradar24 site (may fail behind Cloudflare)
+ auth: test needs flightradar24 credentials (skipped when none configured)
diff --git a/pyflightdata/test_online_auth.py b/pyflightdata/test_online_auth.py
new file mode 100644
index 0000000..2663755
--- /dev/null
+++ b/pyflightdata/test_online_auth.py
@@ -0,0 +1,102 @@
+# MIT License
+#
+# Copyright (c) 2020 Hari Allamraju
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Authenticated online tests against the live flightradar24 site.
+
+These require real credentials (see ``conftest.load_credentials``) and are
+skipped automatically when none are configured, so they never break CI. The
+premise, per the flightradar24 plans, is that anonymous and paid access differ
+mainly in how far back the history goes: a logged-in client should therefore see
+*at least* as much history as an anonymous one within the same page budget.
+"""
+
+import time
+
+import pytest
+from flaky import flaky
+
+from .flightdata import FlightData
+
+
+def delay_rerun(*args):
+ time.sleep(5)
+ return True
+
+
+def _history_depth(client, tail, max_pages=3):
+ """Count history rows for ``tail`` across up to ``max_pages`` pages.
+
+ ``FlightData._fr24`` holds pagination state and is shared across every
+ ``FlightData`` instance, so callers must never interleave two clients'
+ paged reads. We reset that shared state before and after each pass.
+ """
+ client.clear_last_request()
+ total = 0
+ for page in range(1, max_pages + 1):
+ rows = client.get_history_by_tail_number(tail, page=page)
+ total += len(rows)
+ if not rows:
+ break
+ time.sleep(2)
+ client.clear_last_request()
+ return total
+
+
+@pytest.mark.online
+@pytest.mark.auth
+@flaky(max_runs=3, rerun_filter=delay_rerun)
+class TestAuthenticatedAccess(object):
+
+ # A busy long-haul aircraft, so there is plenty of history to page through.
+ TAIL = '9V-SMC'
+
+ def teardown(self):
+ time.sleep(3)
+
+ def test_login_then_logout(self, credentials):
+ email, password = credentials
+ f = FlightData()
+ assert f.is_authenticated() is False
+ assert f.login(email, password) is True
+ assert f.is_authenticated() is True
+ f.logout()
+ assert f.is_authenticated() is False
+
+ def test_login_via_constructor(self, credentials):
+ email, password = credentials
+ f = FlightData(email, password)
+ assert f.is_authenticated() is True
+
+ def test_authenticated_returns_history(self, credentials):
+ email, password = credentials
+ f = FlightData(email, password)
+ assert _history_depth(f, self.TAIL) > 0
+
+ def test_authenticated_history_at_least_anonymous(self, credentials):
+ email, password = credentials
+ # Sequential, never interleaved, because _fr24 pagination state is shared.
+ anon_depth = _history_depth(FlightData(), self.TAIL)
+ auth_depth = _history_depth(FlightData(email, password), self.TAIL)
+ assert auth_depth > 0
+ # Paid history reaches at least as far back as anonymous within the same
+ # page budget; the only advertised difference between the tiers is depth.
+ assert auth_depth >= anon_depth
diff --git a/pyflightdata/test_pyflightdata.py b/pyflightdata/test_pyflightdata.py
index b3b151d..5468c43 100644
--- a/pyflightdata/test_pyflightdata.py
+++ b/pyflightdata/test_pyflightdata.py
@@ -52,11 +52,11 @@ def test_check_there_is_history_data(self):
result = f.get_history_by_flight_number('AI101')
assert result.__len__() > 0
- def test_check_there_is_history_data(self):
+ def test_flight_for_today_has_data(self):
result = f.get_flight_for_date('AI101',self.t)
assert result.__len__() > 0
- def test_check_there_is_history_data(self):
+ def test_flight_for_far_future_has_no_data(self):
result = f.get_flight_for_date('AI101',self.tpls90)
assert result.__len__() == 0
@@ -136,16 +136,16 @@ def test_get_airport_arrivals(self):
def test_get_airport_departures(self):
assert f.get_airport_departures('SIN').__len__() >= 0
- def test_get_airport_departures_earlier(self):
+ def test_get_airport_departures_earlier_pmf(self):
assert f.get_airport_departures('PMF', earlier_data=True).__len__() >= 0
- def test_get_airport_departures_no_earlier(self):
+ def test_get_airport_departures_no_earlier_sin(self):
assert f.get_airport_departures('SIN', earlier_data=False).__len__() >= 0
- def test_get_airport_departures_earlier(self):
+ def test_get_airport_departures_earlier_gau(self):
assert f.get_airport_departures('GAU', earlier_data=True).__len__() >= 0
- def test_get_airport_departures_no_earlier(self):
+ def test_get_airport_departures_no_earlier_gau(self):
assert f.get_airport_departures('GAU', earlier_data=False).__len__() >= 0
def test_get_airport_onground(self):
diff --git a/pyflightdata/test_unit.py b/pyflightdata/test_unit.py
new file mode 100644
index 0000000..a06cb15
--- /dev/null
+++ b/pyflightdata/test_unit.py
@@ -0,0 +1,375 @@
+# MIT License
+#
+# Copyright (c) 2020 Hari Allamraju
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Offline unit tests.
+
+Unlike the other ``test_*`` modules in this package, nothing here touches the
+network. Every test exercises pure parsing/filtering/state logic, so the suite
+runs deterministically in CI where flightradar24 is unreachable (Cloudflare
+blocks GitHub-hosted runners). Anything that would make an HTTP request is
+either fed canned input or monkeypatched.
+"""
+
+import time
+from datetime import datetime
+
+from bs4 import BeautifulSoup
+
+from . import conftest
+from .common import ProcessorMixin
+from .common_fr24 import FR24
+from .flightdata import FlightData
+from .json_helper import fltr
+from .utils import nowtimestamp_millis, totimestamp
+
+
+# ---------------------------------------------------------------------------
+# json_helper.fltr
+# ---------------------------------------------------------------------------
+
+class TestFltr(object):
+
+ def test_removes_top_level_and_nested_keys(self):
+ node = {'id': 1, 'name': 'AI101', 'nested': {'hex': 'abc', 'keep': 'v'}}
+ out = fltr(node, ['id', 'hex'])
+ assert 'id' not in out
+ assert out['name'] == 'AI101'
+ assert 'hex' not in out['nested']
+ assert out['nested']['keep'] == 'v'
+
+ def test_non_string_scalars_are_preserved(self):
+ out = fltr({'count': 3, 'ratio': 1.5}, [])
+ assert out['count'] == 3
+ assert out['ratio'] == 1.5
+
+ def test_bool_is_kept_as_bool(self):
+ # isinstance(True, int) is True in Python, so bools take the int branch
+ # and are returned unchanged rather than stringified.
+ assert fltr({'flag': True}, [])['flag'] is True
+
+ def test_timestamp_keys_are_expanded(self):
+ out = fltr({'updated': 1609459200}, [])
+ assert out['updated'] == 1609459200
+ assert out['updated_millis'] == 1609459200 * 1000
+ # date/time strings are local-tz dependent; assert shape not value
+ assert len(out['updated_date']) == 8 and out['updated_date'].isdigit()
+ assert len(out['updated_time']) == 4 and out['updated_time'].isdigit()
+
+ def test_non_timestamp_int_is_not_expanded(self):
+ out = fltr({'seats': 180}, [])
+ assert out['seats'] == 180
+ assert 'seats_millis' not in out
+
+ def test_empty_dict_returns_none(self):
+ assert fltr({}, []) is None
+
+ def test_dict_reduced_to_empty_returns_none(self):
+ assert fltr({'id': 1}, ['id']) is None
+
+ def test_empty_list_returns_none(self):
+ assert fltr([], []) is None
+
+ def test_list_drops_entries_that_reduce_to_empty(self):
+ out = fltr([{'id': 1}, {'name': 'x'}], ['id'])
+ assert out == [{'name': 'x'}]
+
+
+# ---------------------------------------------------------------------------
+# utils
+# ---------------------------------------------------------------------------
+
+class TestUtils(object):
+
+ def test_totimestamp_epoch_is_zero(self):
+ assert totimestamp(datetime(1970, 1, 1)) == 0
+
+ def test_totimestamp_known_value(self):
+ # 2021-01-01T00:00:00 == 1609459200 seconds since the epoch
+ assert totimestamp(datetime(2021, 1, 1)) == 1609459200
+
+ def test_nowtimestamp_is_int_seconds(self):
+ v = nowtimestamp_millis()
+ assert isinstance(v, int)
+ # within a few seconds of the real epoch-seconds clock
+ assert abs(v - int(time.time())) < 5
+
+
+# ---------------------------------------------------------------------------
+# common.ProcessorMixin
+# ---------------------------------------------------------------------------
+
+class TestProcessorMixin(object):
+
+ p = ProcessorMixin()
+
+ def test_json_loads_byteified_from_bytes(self):
+ out = self.p.json_loads_byteified(b'{"a": "b", "n": 1}')
+ assert out['a'] == 'b'
+ assert out['n'] == 1
+
+ def test_json_loads_byteified_from_str(self):
+ out = self.p.json_loads_byteified('{"nested": {"k": "v"}}')
+ assert out['nested']['k'] == 'v'
+
+ def test_encode_and_get_plain_string(self):
+ assert self.p.encode_and_get('hello') == 'hello'
+
+ def test_encode_and_get_replaces_nbsp_with_space(self):
+ # a non-breaking space (\xa0) should come back as a regular space
+ assert self.p.encode_and_get('a\xa0b') == 'a b'
+
+ def test_get_soup_or_none_on_bad_input(self):
+ # BeautifulSoup happily parses None-ish content only when given bytes/str;
+ # None triggers the guarded except and yields None
+ assert self.p.get_soup_or_none(None) is None
+
+
+# ---------------------------------------------------------------------------
+# common_fr24.FR24 - pure logic, no network
+# ---------------------------------------------------------------------------
+
+def _rows_from_html(html):
+ return BeautifulSoup(html, 'html5lib').find_all('tr')
+
+
+class TestFR24State(object):
+
+ def test_check_last_key_resets_timestamp_on_new_key(self):
+ fr = FR24()
+ fr.timestamp = '999'
+ fr.last_key = 'AI101'
+ fr.check_last_key('AI202')
+ assert fr.timestamp == ''
+ assert fr.last_key == 'AI202'
+
+ def test_check_last_key_keeps_timestamp_for_same_key(self):
+ fr = FR24()
+ fr.timestamp = '999'
+ fr.last_key = 'AI101'
+ fr.check_last_key('AI101')
+ assert fr.timestamp == '999'
+
+ def test_filter_and_get_data_handles_empty(self):
+ fr = FR24()
+ assert fr.filter_and_get_data(None) == []
+ assert fr.filter_and_get_data([]) == []
+
+ def test_filter_and_get_data_filters_noise_keys(self):
+ fr = FR24()
+ out = fr.filter_and_get_data([{'id': 1, 'callsign': 'SIA', 'logo': 'x'}])
+ assert 'id' not in out
+ assert 'logo' not in out
+ assert out['callsign'] == 'SIA'
+
+ def test_process_raw_flight_data_captures_last_timestamp(self):
+ fr = FR24()
+ data = [
+ {'time': {'other': {'updated': 111}}},
+ {'time': {'other': {'updated': 222}}},
+ ]
+ result = fr.process_raw_flight_data(data)
+ assert result is data
+ assert fr.timestamp == 222
+
+ def test_process_raw_flight_data_empty_clears_timestamp(self):
+ fr = FR24()
+ fr.timestamp = 'stale'
+ fr.process_raw_flight_data([])
+ assert fr.timestamp == ''
+
+
+class TestFR24FleetParsing(object):
+
+ FLEET_HTML = (
+ '
'
+ )
+
+ def test_unauthenticated_masks_msn_and_age(self):
+ fr = FR24()
+ out = fr.process_raw_airline_fleet_data(_rows_from_html(self.FLEET_HTML), False)
+ assert out[0]['reg'] == '9V-ABC'
+ assert out[0]['type'] == 'A359'
+ assert out[0]['msn'] == 'requires user login'
+ assert out[0]['age'] == 'requires user login'
+
+ def test_authenticated_exposes_msn_and_age(self):
+ fr = FR24()
+ out = fr.process_raw_airline_fleet_data(_rows_from_html(self.FLEET_HTML), True)
+ assert out[0]['msn'] == '1234'
+ assert out[0]['age'] == '5.6'
+
+ def test_row_without_anchor_is_skipped(self):
+ fr = FR24()
+ html = ''
+ out = fr.process_raw_airline_fleet_data(_rows_from_html(html), False)
+ assert out == []
+
+ def test_short_row_is_skipped(self):
+ fr = FR24()
+ html = ''
+ out = fr.process_raw_airline_fleet_data(_rows_from_html(html), False)
+ assert out == []
+
+ def test_authenticated_short_row_falls_back_to_masked(self):
+ # authenticated but the site only returned reg + type columns
+ fr = FR24()
+ html = ''
+ out = fr.process_raw_airline_fleet_data(_rows_from_html(html), True)
+ assert out[0]['reg'] == '9V-XYZ'
+ assert out[0]['msn'] == 'requires user login'
+
+
+# ---------------------------------------------------------------------------
+# flightdata.FlightData - offline behaviour and monkeypatched I/O
+# ---------------------------------------------------------------------------
+
+class TestFlightDataState(object):
+
+ def test_logout_resets_authentication(self):
+ f = FlightData()
+ f.AUTH_TOKEN = 'tok'
+ assert f.is_authenticated() is True
+ f.logout()
+ assert f.is_authenticated() is False
+
+ def test_fresh_instance_is_not_authenticated(self):
+ assert FlightData().is_authenticated() is False
+
+ def test_clear_last_request_resets_fr24_state(self):
+ f = FlightData()
+ f._fr24.timestamp = 'x'
+ f._fr24.last_key = 'y'
+ f.clear_last_request()
+ assert f._fr24.timestamp == ''
+ assert f._fr24.last_key == ''
+
+
+class TestGetFlightForDate(object):
+
+ def test_keeps_only_flights_matching_the_date(self, monkeypatch):
+ f = FlightData()
+ match = {'time': {'scheduled': {'arrival_date': '20210101'}}}
+ nomatch = {'time': {'scheduled': {'arrival_date': '20991231'}}}
+ monkeypatch.setattr(f, 'get_history_by_flight_number',
+ lambda flight_number: [match, nomatch])
+ assert f.get_flight_for_date('AI101', '20210101') == [match]
+
+ def test_matches_on_departure_date_too(self, monkeypatch):
+ f = FlightData()
+ dep = {'time': {'scheduled': {'departure_date': '20210101'}}}
+ monkeypatch.setattr(f, 'get_history_by_flight_number',
+ lambda flight_number: [dep])
+ assert f.get_flight_for_date('AI101', '20210101') == [dep]
+
+ def test_returns_empty_when_nothing_matches(self, monkeypatch):
+ f = FlightData()
+ monkeypatch.setattr(f, 'get_history_by_flight_number',
+ lambda flight_number: [
+ {'time': {'scheduled': {'arrival_date': '19990101'}}}])
+ assert f.get_flight_for_date('AI101', '20210101') == []
+
+
+class TestGetAirportWeather(object):
+
+ def test_adds_km_when_visibility_present(self, monkeypatch):
+ f = FlightData()
+ weather = {'sky': {'visibility': {'mi': '10'}}}
+ monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: weather)
+ out = f.get_airport_weather('SIN')
+ assert out['sky']['visibility']['km'] == 10 * 1.6094
+
+ def test_no_km_when_visibility_is_none(self, monkeypatch):
+ f = FlightData()
+ weather = {'sky': {'visibility': {'mi': 'None'}}}
+ monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: weather)
+ out = f.get_airport_weather('SIN')
+ assert 'km' not in out['sky']['visibility']
+
+ def test_missing_weather_structure_is_swallowed(self, monkeypatch):
+ f = FlightData()
+ monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: [])
+ # should not raise even though [] has no 'sky' key
+ assert f.get_airport_weather('SIN') == []
+
+
+class TestLogin(object):
+
+ class _Resp(object):
+ def __init__(self, status_code, content):
+ self.status_code = status_code
+ self.content = content
+
+ def test_successful_login_sets_token(self, monkeypatch):
+ f = FlightData()
+ payload = b'{"userData": {"subscriptionKey": "secret-token"}}'
+ monkeypatch.setattr(FlightData.session, 'post',
+ lambda **kw: self._Resp(200, payload))
+ assert f.login('a@b.com', 'pw') is True
+ assert f.AUTH_TOKEN == 'secret-token'
+ f.logout()
+
+ def test_non_200_returns_false(self, monkeypatch):
+ f = FlightData()
+ monkeypatch.setattr(FlightData.session, 'post',
+ lambda **kw: self._Resp(403, b''))
+ assert f.login('a@b.com', 'pw') is False
+ assert f.is_authenticated() is False
+
+ def test_unexpected_shape_returns_false(self, monkeypatch):
+ f = FlightData()
+ monkeypatch.setattr(FlightData.session, 'post',
+ lambda **kw: self._Resp(200, b'{"unexpected": true}'))
+ assert f.login('a@b.com', 'pw') is False
+ assert f.is_authenticated() is False
+
+ def test_network_error_returns_false(self, monkeypatch):
+ f = FlightData()
+
+ def _boom(**kw):
+ raise ConnectionError('down')
+
+ monkeypatch.setattr(FlightData.session, 'post', _boom)
+ assert f.login('a@b.com', 'pw') is False
+
+
+# ---------------------------------------------------------------------------
+# conftest credential loader (offline)
+# ---------------------------------------------------------------------------
+
+class TestCredentialLoader(object):
+
+ def test_env_vars_take_precedence(self, monkeypatch):
+ monkeypatch.setenv('FR24_EMAIL', 'env@x.com')
+ monkeypatch.setenv('FR24_PASSWORD', 'env-pw')
+ assert conftest.load_credentials() == ('env@x.com', 'env-pw')
+
+ def test_reads_key_value_env_file(self, tmp_path):
+ env = tmp_path / '.env'
+ env.write_text('email=a@b.com\npassword="secret"\n# a comment\n\n')
+ values = conftest._read_env_file(str(env))
+ assert values['email'] == 'a@b.com'
+ assert values['password'] == 'secret'
+
+ def test_missing_env_file_yields_empty(self, tmp_path):
+ assert conftest._read_env_file(str(tmp_path / 'absent.env')) == {}
diff --git a/pyflightdata/utils.py b/pyflightdata/utils.py
index 38efcce..d1a4062 100644
--- a/pyflightdata/utils.py
+++ b/pyflightdata/utils.py
@@ -25,12 +25,19 @@
"""
from __future__ import print_function
-from datetime import datetime, timedelta
+from datetime import datetime, timezone
-def totimestamp(dt, epoch=datetime(1970,1,1)):
+
+def totimestamp(dt, epoch=datetime(1970, 1, 1)):
td = dt - epoch
# return td.total_seconds()
- return (td.microseconds + (td.seconds + td.days * 86400) * 10**6) / 10**6
+ return (td.microseconds + (td.seconds + td.days * 86400) * 10 ** 6) / 10 ** 6
+
def nowtimestamp_millis():
- return totimestamp(datetime.utcnow())
\ No newline at end of file
+ # NOTE: despite the historical name, flightradar24's schedule ``timestamp``
+ # parameter expects whole epoch *seconds*, so that is what we return.
+ # ``datetime.utcnow()`` is deprecated as of Python 3.12; use a timezone-aware
+ # UTC ``now`` instead. An int (not a float) is returned so the value goes
+ # into the request URL as e.g. ``1700000000`` rather than ``1700000000.66``.
+ return int(datetime.now(timezone.utc).timestamp())
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index c3b1551..a315ffe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,6 @@ classifiers = [
"License :: OSI Approved :: MIT License",
]
dependencies = [
- "requests",
"curl_cffi",
"lxml>=4.4.1",
"beautifulsoup4>=4.8.1",