FlightRadarAPI 1.5.2__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/.gitignore +1 -0
  2. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/__init__.py +9 -2
  3. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/api.py +30 -13
  4. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/core.py +3 -0
  5. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/errors.py +5 -0
  6. flightradarapi-1.6.0/FlightRadarAPI/parsers.py +239 -0
  7. flightradarapi-1.6.0/FlightRadarAPI/request.py +587 -0
  8. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/PKG-INFO +5 -4
  9. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/README.md +1 -0
  10. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/pyproject.toml +5 -4
  11. flightradarapi-1.5.2/FlightRadar24/__init__.py +0 -37
  12. flightradarapi-1.5.2/FlightRadarAPI/parsers.py +0 -143
  13. flightradarapi-1.5.2/FlightRadarAPI/request.py +0 -282
  14. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/entities/__init__.py +0 -0
  15. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/entities/airport.py +0 -0
  16. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/entities/entity.py +0 -0
  17. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/entities/flight.py +0 -0
  18. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/flight_tracker_config.py +0 -0
  19. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/py.typed +0 -0
  20. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/FlightRadarAPI/zones.py +0 -0
  21. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/LICENSE +0 -0
  22. {flightradarapi-1.5.2 → flightradarapi-1.6.0}/Makefile +0 -0
@@ -13,6 +13,7 @@ venv/
13
13
  .mypy_cache/
14
14
  .pytest_cache/
15
15
  htmlcov/
16
+ site/
16
17
  .coverage
17
18
  coverage.xml
18
19
 
@@ -12,12 +12,18 @@ https://www.flightradar24.com/terms-and-conditions
12
12
  """
13
13
 
14
14
  __author__ = "Jean Loui Bernard Silva de Jesus"
15
- __version__ = "1.5.2"
15
+ __version__ = "1.6.0"
16
16
 
17
17
  from .api import FlightRadar24API
18
18
  from .core import Countries
19
19
  from .entities import Airport, Entity, Flight
20
- from .errors import AirportNotFoundError, CloudflareError, FlightRadarError, LoginError
20
+ from .errors import (
21
+ AirportNotFoundError,
22
+ CloudflareError,
23
+ DecompressionLimitError,
24
+ FlightRadarError,
25
+ LoginError,
26
+ )
21
27
  from .flight_tracker_config import FlightTrackerConfig
22
28
  from .request import RetryPolicy
23
29
 
@@ -29,6 +35,7 @@ __all__ = [
29
35
  "Flight",
30
36
  "AirportNotFoundError",
31
37
  "CloudflareError",
38
+ "DecompressionLimitError",
32
39
  "FlightRadarError",
33
40
  "LoginError",
34
41
  "FlightTrackerConfig",
@@ -3,7 +3,7 @@
3
3
  import dataclasses
4
4
  import math
5
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
- from typing import Any, Dict, List, Optional, Tuple, Union
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
7
7
  from urllib.parse import quote
8
8
 
9
9
  from .core import Core, Countries
@@ -11,7 +11,7 @@ from .entities.airport import Airport
11
11
  from .entities.flight import Flight
12
12
  from .errors import AirportNotFoundError, LoginError
13
13
  from .flight_tracker_config import FlightTrackerConfig
14
- from .parsers import parse_airlines_html, parse_airports_html
14
+ from .parsers import country_to_slug, parse_airlines_html, parse_airports_json
15
15
  from .request import APIClient, RetryPolicy
16
16
 
17
17
  # Some FR24 live-feed backends answer 200 with a well-formed envelope but no
@@ -187,21 +187,33 @@ class FlightRadar24API:
187
187
  )
188
188
  return response.get_json_content()
189
189
 
190
- def get_airports(self, countries: List[Countries]) -> List[Airport]:
190
+ def get_airports(
191
+ self, countries: Optional[Union[Iterable[Union[Countries, str]], Countries, str]] = None,
192
+ ) -> List[Airport]:
191
193
  """
192
- Return a list with all airports for specified countries.
194
+ Return a list with all airports, optionally narrowed to some countries.
193
195
 
194
- :param countries: List of country names from Countries enum.
196
+ :param countries: Country names from the Countries enum, or their slug strings,
197
+ as any iterable or a single value. Every country when omitted.
195
198
  """
196
- def _fetch(country):
197
- href = Core.airports_data_url + "/" + country.value
198
- response = self.__client.request_standalone(href, headers=Core.html_headers, timeout=self.timeout)
199
- return parse_airports_html(response.get_bytes_content(), href)
199
+ wanted: Optional[List[Union[Countries, str]]] = None
200
200
 
201
- with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
202
- results = executor.map(_fetch, countries)
201
+ if isinstance(countries, (Countries, str)):
202
+ wanted = [countries]
203
+ elif countries is not None:
204
+ # Materialised: a generator has no len() and is consumed once.
205
+ wanted = list(countries)
203
206
 
204
- return [airport for result in results for airport in result]
207
+ if wanted is not None and len(wanted) == 0:
208
+ return []
209
+
210
+ response = self.__client.request(
211
+ Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout,
212
+ )
213
+
214
+ # get_content(), not get_json_content(): an html body reaches the parser's
215
+ # guard; a malformed JSON body still raises. The parser slugifies `wanted`.
216
+ return parse_airports_json(response.get_content(), wanted)
205
217
 
206
218
  def get_bookmarks(self) -> Dict:
207
219
  """
@@ -292,7 +304,12 @@ class FlightRadar24API:
292
304
 
293
305
  :param country: Country name
294
306
  """
295
- slug = country.lower().replace(" ", "-")
307
+ # Same slugifier as the feed, which spells some names "Myanmar (Burma)".
308
+ slug = country_to_slug(country)
309
+
310
+ if not slug:
311
+ return None
312
+
296
313
  flag_url = Core.country_flag_url.format(slug)
297
314
  headers = Core.image_headers.copy()
298
315
 
@@ -32,6 +32,9 @@ class Core:
32
32
  airport_data_url = flightradar_base_url + "/airports/traffic-stats/?airport={}"
33
33
  airports_data_url = flightradar_base_url + "/data/airports"
34
34
 
35
+ # Every airport in one payload; the per-country pages above are client-rendered.
36
+ airports_json_url = flightradar_base_url + "/_json/airports.php"
37
+
35
38
  # Airlines data URL.
36
39
  airlines_data_url = flightradar_base_url + "/data/airlines"
37
40
 
@@ -16,5 +16,10 @@ class CloudflareError(FlightRadarError):
16
16
  self.response = response
17
17
 
18
18
 
19
+ class DecompressionLimitError(FlightRadarError):
20
+ """Raised when a response body expands past the decompression budget."""
21
+ pass
22
+
23
+
19
24
  class LoginError(FlightRadarError):
20
25
  pass
@@ -0,0 +1,239 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import json
4
+ import logging
5
+ import math
6
+ import re
7
+ import unicodedata
8
+ from enum import Enum
9
+ from typing import Dict, Iterable, List, Optional, Union
10
+
11
+ from bs4 import BeautifulSoup
12
+
13
+ from .entities.airport import Airport
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+ # ASCII only: \d would otherwise match Unicode digits such as "٤٣".
18
+ NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", re.ASCII)
19
+
20
+ # str.strip() and trim() disagree on U+001C-U+001F and U+FEFF: pick one set.
21
+ SURROUNDING_SPACE = " \t\n\r\f\v"
22
+
23
+ # Number.MAX_SAFE_INTEGER, the largest integer JavaScript holds exactly.
24
+ MAX_EXACT_INTEGER = 2 ** 53 - 1
25
+
26
+
27
+ def parse_airlines_html(html: bytes) -> List[Dict]:
28
+ """
29
+ Parse the airlines listing HTML page into a list of airline dicts.
30
+ """
31
+ soup = BeautifulSoup(html, "html.parser")
32
+ tbody = soup.find("tbody")
33
+
34
+ if not tbody:
35
+ _logger.warning(
36
+ "parse_airlines_html: no <tbody> in response — FR24 page layout may have changed."
37
+ )
38
+ return []
39
+
40
+ airlines = []
41
+
42
+ for tr in tbody.find_all("tr"):
43
+ td_notranslate = tr.find("td", class_="notranslate")
44
+
45
+ if not td_notranslate:
46
+ continue
47
+
48
+ a_element = td_notranslate.find("a", href=lambda href: href and href.startswith("/data/airlines"))
49
+
50
+ if not a_element:
51
+ continue
52
+
53
+ airline_name = a_element.get_text(strip=True)
54
+
55
+ if len(airline_name) < 2:
56
+ continue
57
+
58
+ td_elements = tr.find_all("td")
59
+ iata = None
60
+ icao = None
61
+
62
+ if len(td_elements) >= 4:
63
+ codes_text = td_elements[3].get_text(strip=True)
64
+
65
+ if " / " in codes_text:
66
+ parts = codes_text.split(" / ")
67
+ if len(parts) == 2:
68
+ iata = parts[0].strip()
69
+ icao = parts[1].strip()
70
+ elif len(codes_text) == 2:
71
+ iata = codes_text
72
+ elif len(codes_text) == 3:
73
+ icao = codes_text
74
+
75
+ n_aircrafts = None
76
+
77
+ if len(td_elements) >= 5:
78
+ aircrafts_text = td_elements[4].get_text(strip=True)
79
+ if aircrafts_text:
80
+ n_aircrafts = int(aircrafts_text.split(" ", maxsplit=1)[0].strip())
81
+
82
+ airlines.append({"Name": airline_name, "ICAO": icao, "IATA": iata, "n_aircrafts": n_aircrafts})
83
+
84
+ return airlines
85
+
86
+
87
+ def country_to_slug(country: object) -> str:
88
+ """
89
+ Slugify a country name the way FR24 spells it in its data page URLs, so feed
90
+ rows can be matched against the Countries enum ("United States" -> "united-states").
91
+ """
92
+ # `.value` unwraps a Countries member, which str() would render as
93
+ # "Countries.BRAZIL". Diacritics are stripped so a future "Curaçao" still
94
+ # matches "curacao"; `is None` rather than truthiness because str(0 or "") is
95
+ # "" here while String(0 ?? "") is "0" in Node.
96
+ if isinstance(country, Enum):
97
+ country = country.value
98
+
99
+ decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country))
100
+ ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char))
101
+
102
+ # Punctuation becomes a hyphen rather than being deleted: FR24's own assets are
103
+ # named that way, e.g. flags-small/cote-d-ivoire.svg.
104
+ return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")
105
+
106
+
107
+ def _to_text(value: object) -> str:
108
+ """
109
+ Keep a text field as a string, or "" when the feed sends anything else.
110
+
111
+ Airport documents these as strings and callers treat them as such:
112
+ get_country_flag(airport.country) would slugify a dict into "object-object"
113
+ and request that flag.
114
+ """
115
+ return value if isinstance(value, str) else ""
116
+
117
+
118
+ def _to_number(value: object) -> Optional[Union[int, float]]:
119
+ """
120
+ Coerce a numeric feed field into a number, or None when it is unusable.
121
+
122
+ An unusable coordinate must not become 0.0: that would place the airport in
123
+ the Gulf of Guinea instead of marking its position as unknown.
124
+
125
+ Plain whole numbers stay ints, so an altitude reads 2436 in both ports rather
126
+ than 2436.0 here -- the feed sends `alt` as an int for most rows and as a
127
+ string ("-1") for the rest. Anything written with a decimal point or an
128
+ exponent stays a float: "2436.0" and "1e3" are floats here and integral
129
+ Numbers in Node, equal in value but not in type.
130
+ """
131
+ # bool is an int here, and str([43]) would invent a number.
132
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
133
+ return None
134
+
135
+ if isinstance(value, str):
136
+ stripped = value.strip(SURROUNDING_SPACE)
137
+
138
+ if not NUMERIC_PATTERN.match(stripped):
139
+ return None
140
+
141
+ text: Union[int, float, str] = stripped
142
+ else:
143
+ text = value
144
+
145
+ # One conversion, so overflow is handled once: Python ints have no ceiling.
146
+ try:
147
+ number = float(text)
148
+ except OverflowError:
149
+ return None
150
+
151
+ if not math.isfinite(number):
152
+ return None
153
+
154
+ try:
155
+ exact = int(text)
156
+ except (ValueError, OverflowError):
157
+ return number
158
+
159
+ # int only when lossless (so -23.4 survives) and only where JavaScript is exact.
160
+ return exact if exact == number and abs(exact) <= MAX_EXACT_INTEGER else number
161
+
162
+
163
+ def parse_airports_json(
164
+ payload: Union[bytes, str, Dict], countries: Optional[Iterable[object]] = None,
165
+ ) -> List[Airport]:
166
+ """
167
+ Parse the airports JSON feed into a list of Airport instances.
168
+
169
+ :param payload: Body of Core.airports_json_url.
170
+ :param countries: Country slugs, or Countries members. Every airport is kept when omitted.
171
+ """
172
+ if isinstance(payload, (bytes, str)):
173
+ try:
174
+ data = json.loads(payload)
175
+ except ValueError:
176
+ _logger.warning("parse_airports_json: response is not valid JSON — FR24 feed may have changed.")
177
+ return []
178
+ else:
179
+ data = payload
180
+
181
+ rows = data.get("rows") if isinstance(data, dict) else None
182
+
183
+ if not isinstance(rows, list):
184
+ _logger.warning('parse_airports_json: no "rows" array in response — FR24 feed may have changed.')
185
+ return []
186
+
187
+ wanted = {country_to_slug(country) for country in countries} if countries is not None else None
188
+ matched = set()
189
+ unpositioned = []
190
+ airports = []
191
+
192
+ for row in rows:
193
+ if not isinstance(row, dict):
194
+ continue
195
+
196
+ # Slugified only when filtering: it is otherwise unused, and this loop
197
+ # runs over every airport in the feed.
198
+ if wanted is not None:
199
+ slug = country_to_slug(row.get("country"))
200
+
201
+ if slug not in wanted:
202
+ continue
203
+ matched.add(slug)
204
+
205
+ latitude = _to_number(row.get("lat"))
206
+ longitude = _to_number(row.get("lon"))
207
+
208
+ # One bad coordinate drops both: half a position reads as located.
209
+ if latitude is None or longitude is None:
210
+ latitude = longitude = None
211
+ unpositioned.append(_to_text(row.get("name")))
212
+
213
+ airports.append(Airport(basic_info={
214
+ "name": _to_text(row.get("name")),
215
+ "icao": _to_text(row.get("icao")),
216
+ "iata": _to_text(row.get("iata")),
217
+ "lat": latitude,
218
+ "lon": longitude,
219
+ "alt": _to_number(row.get("alt")),
220
+ "country": _to_text(row.get("country")),
221
+ }))
222
+
223
+ # One line, not one per row: the feed carries every airport.
224
+ if unpositioned:
225
+ _logger.warning(
226
+ "parse_airports_json: %d airport(s) had unusable coordinates and carry no position (e.g. %s).",
227
+ len(unpositioned), ", ".join(repr(name) for name in unpositioned[:3]),
228
+ )
229
+
230
+ if wanted is not None:
231
+ missing = sorted(wanted - matched)
232
+
233
+ if missing:
234
+ _logger.warning(
235
+ "parse_airports_json: no airports found for %s — check the Countries enum.",
236
+ ", ".join(missing),
237
+ )
238
+
239
+ return airports