FlightRadarAPI 1.5.3__tar.gz → 1.6.1__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 (21) hide show
  1. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/.gitignore +4 -0
  2. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/__init__.py +9 -2
  3. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/errors.py +5 -0
  4. flightradarapi-1.6.1/FlightRadarAPI/request.py +587 -0
  5. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/PKG-INFO +10 -7
  6. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/README.md +6 -3
  7. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/pyproject.toml +5 -4
  8. flightradarapi-1.5.3/FlightRadar24/__init__.py +0 -37
  9. flightradarapi-1.5.3/FlightRadarAPI/request.py +0 -298
  10. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/api.py +0 -0
  11. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/core.py +0 -0
  12. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/entities/__init__.py +0 -0
  13. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/entities/airport.py +0 -0
  14. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/entities/entity.py +0 -0
  15. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/entities/flight.py +0 -0
  16. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/flight_tracker_config.py +0 -0
  17. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/parsers.py +0 -0
  18. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/py.typed +0 -0
  19. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/FlightRadarAPI/zones.py +0 -0
  20. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/LICENSE +0 -0
  21. {flightradarapi-1.5.3 → flightradarapi-1.6.1}/Makefile +0 -0
@@ -13,12 +13,16 @@ venv/
13
13
  .mypy_cache/
14
14
  .pytest_cache/
15
15
  htmlcov/
16
+ site/
16
17
  .coverage
17
18
  coverage.xml
18
19
 
19
20
  # Node.js
20
21
  node_modules/
21
22
 
23
+ # Go
24
+ coverage.out
25
+
22
26
  # Version managers (local dev only)
23
27
  .tool-versions
24
28
  .python-version
@@ -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.3"
15
+ __version__ = "1.6.1"
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",
@@ -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,587 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import json
4
+ import logging
5
+ import random
6
+ import time
7
+ import zlib
8
+ from typing import Any, Dict, List, Optional, Union
9
+ from urllib.parse import urlencode
10
+
11
+ import brotli
12
+ from curl_cffi import CurlECode, CurlOpt, requests
13
+ from curl_cffi.requests import Session
14
+
15
+ from .errors import CloudflareError, DecompressionLimitError
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+ DEFAULT_IMPERSONATE = "chrome136"
20
+
21
+ # A compressed body is trusted only as far as its expanded size: brotli reaches
22
+ # ratios high enough to exhaust memory from a few kilobytes on the wire.
23
+ #
24
+ # Enforcing that needs the compressed bytes, which means taking content decoding
25
+ # away from libcurl (see `_keep_body_encoded`): left to itself it expands the body
26
+ # before any of this code runs, and the only other way to intervene —
27
+ # `stream=True` — was measured to cost more than the bomb it stops: `timeout`
28
+ # degrades to a >=1 byte/sec liveness check, which alone rules it out, and the
29
+ # session stops reusing connections (the standalone path already opens one per
30
+ # call, so that second cost lands on the session path only).
31
+ # Owning the decoding is why `deflate` is implemented below rather than left to
32
+ # the transport: whatever `accept-encoding` advertises, this module must decode.
33
+ MAX_RESPONSE_BYTES = 64 * 1024 * 1024
34
+ _ZLIB_WBITS = 15 # zlib-wrapped deflate, as RFC 9110 specifies
35
+ _RAW_DEFLATE_WBITS = -15 # raw deflate, as many servers actually send
36
+ _GZIP_WBITS = 31 # 16 + MAX_WBITS: gzip wrapper rather than raw deflate
37
+ _GZIP_MAGIC = b"\x1f\x8b"
38
+
39
+
40
+ def _bound_download(session: Session, limit: int) -> None:
41
+ """Have libcurl abort a response body larger than ``limit``.
42
+
43
+ Bounds the bytes as received, which the post-hoc length check cannot: by
44
+ the time it runs, libcurl has buffered the whole body. Aborts both when
45
+ `Content-Length` announces the size and mid-transfer when it does not.
46
+ """
47
+ session.curl.setopt(CurlOpt.MAXFILESIZE_LARGE, limit)
48
+
49
+
50
+ def _keep_body_encoded(session: Session) -> None:
51
+ """Stop libcurl decompressing this session's next response.
52
+
53
+ libcurl decompresses transparently, which would expand a bomb before this
54
+ module ever sees it. With decoding off the compressed bytes arrive intact
55
+ and the helpers below can enforce a real budget. `Accept-Encoding` is still
56
+ sent, so responses stay compressed on the wire.
57
+
58
+ Applied per request, not once at construction: `Session.request` resets the
59
+ handle, so an option set in the constructor survives the first call and
60
+ silently lapses on every one after it. Setting it here also leaves
61
+ curl_cffi's thread-local handles in place.
62
+ """
63
+ session.curl.setopt(CurlOpt.HTTP_CONTENT_DECODING, 0)
64
+
65
+
66
+ def _decompress_deflate(data: bytes, limit: int = MAX_RESPONSE_BYTES) -> bytes:
67
+ """Inflate a deflate body in either shape it arrives in.
68
+
69
+ RFC 9110 says zlib-wrapped; plenty of servers send raw. libcurl accepted
70
+ both, so taking decoding over means accepting both too.
71
+ """
72
+ for wbits in (_ZLIB_WBITS, _RAW_DEFLATE_WBITS):
73
+ decompressor = zlib.decompressobj(wbits)
74
+
75
+ try:
76
+ output = decompressor.decompress(data, limit + 1)
77
+ except zlib.error:
78
+ continue
79
+
80
+ if len(output) > limit or decompressor.unconsumed_tail:
81
+ raise DecompressionLimitError(
82
+ f"deflate body expands past the {limit} byte decompression limit."
83
+ )
84
+
85
+ if decompressor.eof:
86
+ # Raw deflate carries neither a header nor a checksum, so a body
87
+ # that is not deflate at all can still inflate to plausible bytes;
88
+ # requiring the whole input to be consumed is the only tell there
89
+ # is. The zlib wrapper has an adler32 and validates itself, so
90
+ # applying the same rule there would only reject a legitimate body
91
+ # that arrived with trailing padding.
92
+ if wbits == _RAW_DEFLATE_WBITS and decompressor.unused_data:
93
+ continue
94
+
95
+ return output
96
+
97
+ raise zlib.error("body is not a complete deflate stream")
98
+
99
+
100
+ def _decompress_gzip(data: bytes, limit: int = MAX_RESPONSE_BYTES) -> bytes:
101
+ """Inflate gzip bytes, refusing a body that expands past ``limit``.
102
+
103
+ A short read must raise rather than return what arrived: the caller cannot
104
+ tell truncated JSON from a malformed feed, and `get_content` treats a raised
105
+ error as "the transport already decoded this" and hands back the raw bytes.
106
+ """
107
+ # Collected rather than concatenated: `output += ...` copies everything so
108
+ # far on each member, which is quadratic and measured 50x slower on a
109
+ # 500-member body. Joining also beats a bytearray, which pays one more
110
+ # full copy converting back to bytes on the single-member path that
111
+ # nearly every response takes.
112
+ members = []
113
+ decoded = 0
114
+ remaining = data
115
+
116
+ while remaining:
117
+ # A new object per member: `Content-Encoding: gzip` may carry several,
118
+ # and one decompressor stops at the first trailer.
119
+ decompressor = zlib.decompressobj(_GZIP_WBITS)
120
+ member = decompressor.decompress(remaining, limit + 1 - decoded)
121
+ members.append(member)
122
+ decoded += len(member)
123
+
124
+ if decoded > limit or decompressor.unconsumed_tail:
125
+ raise DecompressionLimitError(
126
+ f"gzip body expands past the {limit} byte decompression limit."
127
+ )
128
+
129
+ if not decompressor.eof:
130
+ raise zlib.error("gzip stream ended mid-member")
131
+
132
+ # Another member only when the tail looks like one. Trailing padding is
133
+ # not an error: libcurl stopped at the stream end and ignored it, and
134
+ # the deflate helper tolerates the same thing.
135
+ tail = decompressor.unused_data
136
+ remaining = tail if tail.startswith(_GZIP_MAGIC) else b""
137
+
138
+ return members[0] if len(members) == 1 else b"".join(members)
139
+
140
+
141
+ def _decompress_brotli(data: bytes, limit: int = MAX_RESPONSE_BYTES) -> bytes:
142
+ """Decompress brotli bytes, refusing a body that expands past ``limit``.
143
+
144
+ ``process`` takes a max-output argument, so the cap is enforced by the
145
+ decompressor rather than checked after the fact: the cost tracks ``limit``
146
+ rather than however far the body would have expanded.
147
+
148
+ Pieces are collected and joined, as in the gzip helper. Growing a bytearray
149
+ and converting it back pays two full copies of the output and measured 3x
150
+ the decoded size at peak, against 1x here — the usual single-piece response
151
+ is returned without being copied at all.
152
+ """
153
+ decompressor = brotli.Decompressor()
154
+ pieces = []
155
+ decoded = 0
156
+ fed = False
157
+
158
+ while not decompressor.is_finished():
159
+ room = limit + 1 - decoded
160
+
161
+ if room <= 0:
162
+ raise DecompressionLimitError(
163
+ f"brotli body expands past the {limit} byte decompression limit."
164
+ )
165
+
166
+ piece = decompressor.process(data if not fed else b"", room)
167
+ fed = True
168
+ pieces.append(piece)
169
+ decoded += len(piece)
170
+
171
+ if decoded > limit:
172
+ raise DecompressionLimitError(
173
+ f"brotli body expands past the {limit} byte decompression limit."
174
+ )
175
+
176
+ # No output left and still hungry: nothing more will come.
177
+ if not piece and decompressor.can_accept_more_data():
178
+ break
179
+
180
+ # Same contract as the gzip helper: a partial body must not pass as whole.
181
+ if not decompressor.is_finished():
182
+ raise brotli.error("brotli stream ended mid-message")
183
+
184
+ return pieces[0] if len(pieces) == 1 else b"".join(pieces)
185
+
186
+
187
+ class RetryPolicy:
188
+ """
189
+ Retry policy for transient errors (CloudflareError + curl_cffi network errors).
190
+
191
+ :param max_attempts: total number of attempts including the first one (>= 1).
192
+ :param base_delay: seconds for the first backoff sleep.
193
+ :param max_delay: cap for the exponential backoff.
194
+ :param jitter: random factor [0, jitter) added to each sleep.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ max_attempts: int = 1,
200
+ base_delay: float = 1.0,
201
+ max_delay: float = 30.0,
202
+ jitter: float = 0.5,
203
+ ):
204
+ if max_attempts < 1:
205
+ raise ValueError("max_attempts must be >= 1")
206
+ if base_delay < 0 or max_delay < 0 or jitter < 0:
207
+ raise ValueError("base_delay, max_delay and jitter must all be >= 0")
208
+ self.max_attempts = max_attempts
209
+ self.base_delay = base_delay
210
+ self.max_delay = max_delay
211
+ self.jitter = jitter
212
+
213
+ def sleep_for(self, attempt_index: int) -> float:
214
+ """Return the backoff (in seconds) for the given 0-based attempt index."""
215
+ delay = min(self.base_delay * (2 ** attempt_index), self.max_delay)
216
+ return delay + random.uniform(0, self.jitter)
217
+
218
+
219
+ def _run_with_retry(fn, retry: Optional[RetryPolicy]):
220
+ """Execute ``fn()`` with retry on CloudflareError / transient network errors."""
221
+ if retry is None or retry.max_attempts <= 1:
222
+ return fn()
223
+
224
+ last_error: Optional[Exception] = None
225
+ for attempt in range(retry.max_attempts):
226
+ try:
227
+ return fn()
228
+ except CloudflareError as err:
229
+ last_error = err
230
+ except requests.errors.RequestsError as err: # type: ignore[attr-defined]
231
+ last_error = err
232
+ if attempt < retry.max_attempts - 1:
233
+ time.sleep(retry.sleep_for(attempt))
234
+ # Defensive: with max_attempts > 1, last_error is always populated by the
235
+ # time we reach here, but don't rely on `assert` because it is stripped by
236
+ # `python -O` and would turn into `raise None` -> TypeError.
237
+ if last_error is None:
238
+ raise RuntimeError("retry loop exited without success or captured error")
239
+ raise last_error
240
+
241
+
242
+ class APIClient:
243
+ """
244
+ Central HTTP client for the FlightRadarAPI package.
245
+
246
+ Owns the persistent session (cookie jar, TLS fingerprint, future bypass logic)
247
+ so that the rest of the codebase never has to deal with those concerns directly.
248
+
249
+ :param impersonate: curl_cffi browser profile to mimic. Defaults to
250
+ ``DEFAULT_IMPERSONATE`` (currently ``"chrome136"``). When FR24 updates its
251
+ Cloudflare bot mitigation, pass a newer profile (e.g. ``"chrome137"``,
252
+ ``"chrome138"``) without waiting for a library release.
253
+ """
254
+
255
+ def __init__(
256
+ self,
257
+ impersonate: str = DEFAULT_IMPERSONATE,
258
+ retry: Optional[RetryPolicy] = None,
259
+ ) -> None:
260
+ self.__impersonate = impersonate
261
+ self.__retry = retry
262
+ self.__session: Session = Session(impersonate=impersonate) # type: ignore[arg-type]
263
+
264
+ def request(self, url: str, **kwargs) -> "APIRequest":
265
+ """Make a request through the shared session."""
266
+ return _run_with_retry(
267
+ lambda: APIRequest(url, session=self.__session, **kwargs),
268
+ self.__retry,
269
+ )
270
+
271
+ def request_standalone(self, url: str, **kwargs) -> "APIRequest":
272
+ """Make a stateless request with no shared session (safe to call from threads).
273
+
274
+ The TLS impersonation profile is inherited from this client so that
275
+ thread-pool fan-outs still mimic the same browser as the session.
276
+ """
277
+ return _run_with_retry(
278
+ lambda: APIRequest(url, impersonate=self.__impersonate, **kwargs),
279
+ self.__retry,
280
+ )
281
+
282
+ def get_cookie(self, name: str) -> Optional[str]:
283
+ """Return the value of a stored cookie by name."""
284
+ return self.__session.cookies.get(name)
285
+
286
+ def clear_cookies(self) -> None:
287
+ """Clear all cookies from the session."""
288
+ self.__session.cookies.clear()
289
+
290
+ def delete_cookie(self, name: str) -> None:
291
+ """Drop a single cookie, leaving the rest of the jar intact.
292
+
293
+ Sheds load-balancer stickiness without discarding the login session,
294
+ which lives in the same jar.
295
+ """
296
+ try:
297
+ del self.__session.cookies[name]
298
+ except KeyError:
299
+ pass
300
+
301
+
302
+ class APIRequest:
303
+ """
304
+ Class to make requests to the FlightRadar24.
305
+ """
306
+ # Ordered as Chrome sends them, since this is what `Accept-Encoding`
307
+ # advertises. "" and "identity" mean "no encoding", not a decoder.
308
+ __content_encodings = {
309
+ "": lambda data, limit: data,
310
+ "identity": lambda data, limit: data,
311
+ "gzip": _decompress_gzip,
312
+ "deflate": _decompress_deflate,
313
+ "br": _decompress_brotli,
314
+ }
315
+
316
+ #: Advertised on every request, because taking decoding from libcurl means
317
+ #: only asking for what can be decoded here. curl_cffi's impersonation
318
+ #: otherwise defaults to "gzip, deflate, br, zstd", and a zstd reply would
319
+ #: arrive as bytes nothing here can read. Derived rather than written out,
320
+ #: so advertising an encoding without a decoder is not expressible.
321
+ supported_encodings = ", ".join(
322
+ name for name in __content_encodings if name not in ("", "identity")
323
+ )
324
+
325
+ def __init__(
326
+ self,
327
+ url: str,
328
+ *,
329
+ session: Optional[Session] = None,
330
+ params: Optional[Dict] = None,
331
+ headers: Optional[Dict] = None,
332
+ timeout: int = 30,
333
+ data: Optional[Dict] = None,
334
+ allowed_error_codes: Optional[List[int]] = None,
335
+ impersonate: str = DEFAULT_IMPERSONATE,
336
+ max_response_bytes: int = MAX_RESPONSE_BYTES,
337
+ max_download_bytes: Optional[int] = None,
338
+ ):
339
+ """
340
+ Constructor of the APIRequest class.
341
+
342
+ :param url: URL for the request
343
+ :param session: session to reuse across requests; handles cookies automatically
344
+ :param params: params that will be inserted on the URL for the request
345
+ :param headers: headers for the request
346
+ :param data: data for the request. If "data" is None, request will be a GET. Otherwise, it will be a POST
347
+ :param allowed_error_codes: status codes that should not raise an error
348
+ :param impersonate: curl_cffi browser profile (only used when no session is provided)
349
+ :param max_response_bytes: reject a body that expands past this many bytes
350
+ :param max_download_bytes: reject a body larger than this on the wire.
351
+ Defaults to ``max_response_bytes``. Separate because compression can
352
+ grow incompressible data, so a body that expands to just under the
353
+ budget may still arrive slightly over it.
354
+ """
355
+ if max_response_bytes < 1:
356
+ raise ValueError("max_response_bytes must be >= 1")
357
+
358
+ if max_download_bytes is None:
359
+ max_download_bytes = max_response_bytes
360
+ elif max_download_bytes < 1:
361
+ raise ValueError("max_download_bytes must be >= 1")
362
+
363
+ self.url = url
364
+ self.__max_response_bytes = max_response_bytes
365
+ headers = self.__with_supported_encodings(headers)
366
+
367
+ if params: url += "?" + urlencode(params)
368
+
369
+ try:
370
+ if session is not None:
371
+ _keep_body_encoded(session)
372
+ _bound_download(session, max_download_bytes)
373
+ request_method = session.get if data is None else session.post
374
+ self.__response = request_method(url, headers=headers, data=data, timeout=timeout)
375
+ else:
376
+ # A throwaway session rather than the module-level helpers, whose
377
+ # internal handle this cannot reach.
378
+ with Session(impersonate=impersonate) as standalone: # type: ignore[arg-type]
379
+ _keep_body_encoded(standalone)
380
+ _bound_download(standalone, max_download_bytes)
381
+ request_method = standalone.get if data is None else standalone.post
382
+ self.__response = request_method(url, headers=headers, data=data, timeout=timeout)
383
+ except requests.errors.RequestsError as err: # type: ignore[attr-defined]
384
+ # Not a transient failure, so it must not reach the retry policy as one.
385
+ if getattr(err, "code", None) == CurlECode.FILESIZE_EXCEEDED:
386
+ raise DecompressionLimitError(
387
+ f"Response body from {self.url} is larger than the "
388
+ f"{max_download_bytes} byte download limit."
389
+ ) from err
390
+ raise
391
+
392
+ received = self.__response.content
393
+
394
+ # Three checks guard the size, each covering what the others cannot:
395
+ # MAXFILESIZE_LARGE stops the download at the socket, the decoders stop
396
+ # an expansion as it happens, and this one is the backstop for a
397
+ # transport that honours neither. Unreachable today, kept because it
398
+ # costs one comparison.
399
+ if len(received) > max_download_bytes:
400
+ raise DecompressionLimitError(
401
+ f"Response body from {self.url} is {len(received)} bytes, "
402
+ f"past the {max_download_bytes} byte download limit."
403
+ )
404
+
405
+ self.__content = self.__decode_body(received)
406
+
407
+ # The decoders enforce the budget as they expand, but identity bodies
408
+ # and encodings with no decoder never reach one. Checked here so the
409
+ # budget means the same thing whatever arrived.
410
+ if len(self.__content) > max_response_bytes:
411
+ raise DecompressionLimitError(
412
+ f"Response body from {self.url} is {len(self.__content)} bytes, "
413
+ f"past the {max_response_bytes} byte limit."
414
+ )
415
+
416
+ # `get_response_object()` and `CloudflareError.response` are public, and
417
+ # a challenge page is the first thing anyone reads when debugging a
418
+ # block. Since libcurl no longer decodes, hand them the decoded body.
419
+ self.__response.content = self.__content
420
+
421
+ # Cloudflare detection only when the caller did not opt-in to this status code.
422
+ # `getAirlineLogo`/`getCountryFlag` allow 403 to mean "asset not found" on the CDN.
423
+ if (self.get_status_code() not in (allowed_error_codes or [])
424
+ and self.__is_cloudflare_block()):
425
+ raise CloudflareError(
426
+ message="Blocked by Cloudflare. Perhaps you are making too many calls, "
427
+ "or the TLS impersonation needs to be updated.",
428
+ response=self.__response
429
+ )
430
+
431
+ if self.get_status_code() not in (allowed_error_codes or []):
432
+ self.__response.raise_for_status()
433
+
434
+ @classmethod
435
+ def __with_supported_encodings(cls, headers: Optional[Dict]) -> Optional[Dict]:
436
+ """Ask only for encodings this class can decode."""
437
+ if headers and any(name.lower() == "accept-encoding" for name in headers):
438
+ return headers
439
+
440
+ merged = dict(headers or {})
441
+ merged["accept-encoding"] = cls.supported_encodings
442
+ return merged
443
+
444
+ def __is_cloudflare_block(self) -> bool:
445
+ """
446
+ Detect Cloudflare-level blocks.
447
+
448
+ FR24 fronts the public site with Cloudflare, so a `Server: cloudflare`
449
+ header is present on *every* response — including legitimate 403s from
450
+ the FR24 origin (e.g. premium-only endpoints accessed by a free
451
+ account). To avoid false positives we rely on signals that Cloudflare
452
+ sets only when its own bot-management / challenge actually took
453
+ action:
454
+
455
+ - HTTP 520 (Cloudflare's "unknown error from origin").
456
+ - HTTP 403 with the `cf-mitigated` header set — Cloudflare adds this
457
+ specifically when it (not the origin) decided to block the request.
458
+ """
459
+ status = self.get_status_code()
460
+ if status == 520:
461
+ return True
462
+ if status != 403:
463
+ return False
464
+ return bool(self.__response.headers.get("cf-mitigated"))
465
+
466
+ def __decode_body(self, content: bytes) -> bytes:
467
+ """Undo the `Content-Encoding` this response arrived with.
468
+
469
+ The header may stack encodings ("gzip, br" means gzip then brotli), so
470
+ they are undone in reverse. Values are case-insensitive and may carry
471
+ whitespace, so each token is normalised: an exact match would send
472
+ `GZIP` down the identity path and return compressed bytes as content.
473
+ A token with no decoder warns rather than passing silently, since
474
+ nothing downstream can act on the result.
475
+ """
476
+ content_encoding = self.__response.headers.get("Content-Encoding", "") or ""
477
+ tokens = [token.strip().lower() for token in content_encoding.split(",")]
478
+ applied = [token for token in tokens if token and token != "identity"]
479
+
480
+ if not applied:
481
+ return content
482
+
483
+ decoders = []
484
+
485
+ for token in applied:
486
+ decode = self.__content_encodings.get(token)
487
+
488
+ if decode is None:
489
+ _logger.warning(
490
+ "APIRequest: no decoder for Content-Encoding=%r on %s. Returning the body "
491
+ "as received, which callers will not be able to read.",
492
+ content_encoding, self.url,
493
+ )
494
+ return content
495
+
496
+ decoders.append(decode)
497
+
498
+ received = content
499
+ failed_at = applied[-1]
500
+
501
+ try:
502
+ for token, decode in zip(reversed(applied), reversed(decoders)):
503
+ failed_at = token
504
+ content = decode(content, self.__max_response_bytes)
505
+
506
+ return content
507
+ except DecompressionLimitError:
508
+ raise
509
+ except Exception as err:
510
+ # Back to the bytes as received. A chain that failed halfway leaves
511
+ # a half-decoded intermediate, and the recovery below is a claim
512
+ # about the body that arrived, not about a partial result.
513
+ content = received
514
+ # Reached when the body is not encoded the way the header claims —
515
+ # most often a transport that decoded it after all. Decided by the
516
+ # body, not the header: undecodable text is genuinely broken and
517
+ # must warn, while binary bodies carry no such tell. Nothing here
518
+ # may raise, or it would replace `err` with its own failure.
519
+ content_type = self.__response.headers.get("Content-Type", "")
520
+
521
+ if not isinstance(content, bytes):
522
+ transport_decoded = True
523
+ elif content_type.startswith(("application/json", "text/")):
524
+ try:
525
+ content.decode("utf-8")
526
+ transport_decoded = True
527
+ except UnicodeDecodeError:
528
+ transport_decoded = False
529
+ else:
530
+ corrupt_gzip = failed_at == "gzip" and content.startswith(_GZIP_MAGIC)
531
+ transport_decoded = failed_at in ("gzip", "br", "deflate") and not corrupt_gzip
532
+
533
+ _logger.log(
534
+ logging.DEBUG if transport_decoded else logging.WARNING,
535
+ "APIRequest: failed to decode Content-Encoding=%r for %s (%s). "
536
+ "Assuming the body arrived already decoded and returning it as-is.",
537
+ content_encoding, self.url, err,
538
+ )
539
+ return content
540
+
541
+ def get_content(self) -> Union[Dict, bytes]:
542
+ """
543
+ Return the received content from the request.
544
+ """
545
+ content_type = self.__response.headers.get("Content-Type", "")
546
+
547
+ # Return a dictionary if the content type is JSON.
548
+ if "application/json" in content_type:
549
+ return json.loads(self.__content)
550
+
551
+ return self.__content
552
+
553
+ def get_json_content(self) -> Dict[str, Any]:
554
+ """
555
+ Return the response content as a parsed JSON dictionary.
556
+ """
557
+ content = self.get_content()
558
+ if not isinstance(content, dict):
559
+ raise ValueError(f"Expected JSON response from {self.url}, got bytes")
560
+ return content
561
+
562
+ def get_bytes_content(self) -> bytes:
563
+ """
564
+ Return the response content as raw bytes.
565
+ """
566
+ content = self.get_content()
567
+ if not isinstance(content, bytes):
568
+ raise ValueError(f"Expected bytes response from {self.url}, got JSON")
569
+ return content
570
+
571
+ def get_headers(self) -> Any:
572
+ """
573
+ Return the headers of the response.
574
+ """
575
+ return self.__response.headers
576
+
577
+ def get_response_object(self) -> Any:
578
+ """
579
+ Return the received response object.
580
+ """
581
+ return self.__response
582
+
583
+ def get_status_code(self) -> int:
584
+ """
585
+ Return the status code of the response.
586
+ """
587
+ return self.__response.status_code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: FlightRadarAPI
3
- Version: 1.5.3
3
+ Version: 1.6.1
4
4
  Summary: SDK for FlightRadar24
5
5
  Project-URL: Homepage, https://github.com/JeanExtreme002/FlightRadarAPI
6
6
  Project-URL: Source Code, https://github.com/JeanExtreme002/FlightRadarAPI
@@ -17,25 +17,28 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Requires-Python: >=3.10
20
- Requires-Dist: beautifulsoup4
21
- Requires-Dist: brotli
22
- Requires-Dist: curl-cffi
20
+ Requires-Dist: beautifulsoup4>=4.12.0
21
+ Requires-Dist: brotli>=1.2.0
22
+ Requires-Dist: curl-cffi>=0.15.0
23
23
  Provides-Extra: tests
24
24
  Requires-Dist: pytest; extra == 'tests'
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # FlightRadarAPI
28
- Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js.
28
+ Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python.
29
29
 
30
30
  This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions).
31
31
 
32
32
  **Official FR24 API**: https://fr24api.flightradar24.com/
33
33
 
34
34
  [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
35
- [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/)
36
35
  [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI)
36
+ [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/)
37
37
  [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/)
38
38
  [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi)
39
+ [![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi)
40
+ [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi)
41
+ [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/)
39
42
  [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/)
40
43
  [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/)
41
44
 
@@ -70,4 +73,4 @@ zones = fr_api.get_zones()
70
73
  ```
71
74
 
72
75
  ## Documentation
73
- Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/).
76
+ Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/).
@@ -1,15 +1,18 @@
1
1
  # FlightRadarAPI
2
- Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python 3 and Node.js.
2
+ Unofficial SDK for [FlightRadar24](https://www.flightradar24.com/) for Python.
3
3
 
4
4
  This SDK should only be used for your own educational purposes. If you are interested in accessing Flightradar24 data commercially, please contact business@fr24.com. See more information at [Flightradar24's terms and conditions](https://www.flightradar24.com/terms-and-conditions).
5
5
 
6
6
  **Official FR24 API**: https://fr24api.flightradar24.com/
7
7
 
8
8
  [![Python Package](https://github.com/JeanExtreme002/FlightRadarAPI/workflows/Python%20Package/badge.svg)](https://github.com/JeanExtreme002/FlightRadarAPI/actions)
9
- [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/)
10
9
  [![License](https://img.shields.io/pypi/l/FlightRadarAPI)](https://github.com/JeanExtreme002/FlightRadarAPI)
10
+ [![Pypi](https://img.shields.io/pypi/v/FlightRadarAPI?logo=pypi)](https://pypi.org/project/FlightRadarAPI/)
11
11
  [![Python Version](https://img.shields.io/badge/python-3.10+-8A2BE2)](https://pypi.org/project/FlightRadarAPI/)
12
12
  [![Npm](https://img.shields.io/npm/v/flightradarapi?logo=npm&color=red)](https://www.npmjs.com/package/flightradarapi)
13
+ [![Node Version](https://img.shields.io/badge/node-18.17+-339933)](https://www.npmjs.com/package/flightradarapi)
14
+ [![Go Reference](https://pkg.go.dev/badge/github.com/JeanExtreme002/FlightRadarAPI/go.svg)](https://pkg.go.dev/github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi)
15
+ [![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8)](https://go.dev/dl/)
13
16
  [![Downloads](https://static.pepy.tech/personalized-badge/flightradarapi?period=total&units=international_system&left_color=grey&right_color=orange&left_text=downloads)](https://pypi.org/project/FlightRadarAPI/)
14
17
  [![Frequency](https://img.shields.io/pypi/dm/flightradarapi?style=flat&label=frequency)](https://pypi.org/project/FlightRadarAPI/)
15
18
 
@@ -44,4 +47,4 @@ zones = fr_api.get_zones()
44
47
  ```
45
48
 
46
49
  ## Documentation
47
- Explore the documentation of FlightRadarAPI package, for Python or NodeJS, through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/).
50
+ Explore the documentation of FlightRadarAPI package through [this site](https://JeanExtreme002.github.io/FlightRadarAPI/).
@@ -18,16 +18,17 @@ classifiers = [
18
18
  ]
19
19
  requires-python = ">=3.10"
20
20
  dependencies = [
21
- "Brotli",
22
- "beautifulsoup4",
23
- "curl_cffi",
21
+ # Security floors: curl_cffi 0.15.0 fixes redirect-based SSRF (GHSA-qw2m-4pqf-rmpp).
22
+ "Brotli>=1.2.0",
23
+ "beautifulsoup4>=4.12.0",
24
+ "curl_cffi>=0.15.0",
24
25
  ]
25
26
 
26
27
  [tool.hatch.build]
27
28
  exclude = ["tests", ".flake8"]
28
29
 
29
30
  [tool.hatch.build.targets.wheel]
30
- packages = ["FlightRadarAPI", "FlightRadar24"]
31
+ packages = ["FlightRadarAPI"]
31
32
 
32
33
  # Force-include the PEP 561 marker so downstream type checkers always see it,
33
34
  # regardless of future changes to the global exclude list above.
@@ -1,37 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- """
4
- Deprecated import alias for the FlightRadarAPI SDK.
5
-
6
- The package was renamed to ``FlightRadarAPI`` so the Python import name
7
- matches the PyPI distribution name and the Node.js package name. This
8
- module re-exports the public API and aliases every submodule so legacy
9
- imports such as ``from FlightRadar24 import FlightRadar24API`` or
10
- ``from FlightRadar24.errors import CloudflareError`` keep working, but a
11
- ``DeprecationWarning`` is emitted on import.
12
- """
13
-
14
- import importlib
15
- import pkgutil
16
- import sys
17
- import warnings
18
-
19
- import FlightRadarAPI as _pkg
20
-
21
- warnings.warn(
22
- "Importing from 'FlightRadar24' is deprecated and will be removed in a "
23
- "future release. Import from 'FlightRadarAPI' instead "
24
- "(e.g. 'from FlightRadarAPI import FlightRadar24API').",
25
- DeprecationWarning,
26
- stacklevel=2,
27
- )
28
-
29
- # Mirror every submodule of FlightRadarAPI under the legacy FlightRadar24
30
- # namespace so dotted imports keep resolving without touching disk.
31
- for _info in pkgutil.walk_packages(_pkg.__path__, prefix=f"{_pkg.__name__}."):
32
- _mod = importlib.import_module(_info.name)
33
- sys.modules[_info.name.replace(_pkg.__name__, __name__, 1)] = _mod
34
- del _info, _mod
35
-
36
- from FlightRadarAPI import * # noqa: E402, F401, F403
37
- from FlightRadarAPI import __all__, __author__, __version__ # noqa: E402, F401
@@ -1,298 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- import gzip
4
- import json
5
- import logging
6
- import random
7
- import time
8
- from typing import Any, Dict, List, Optional, Union
9
- from urllib.parse import urlencode
10
-
11
- import brotli
12
- from curl_cffi import requests
13
- from curl_cffi.requests import Session
14
-
15
- from .errors import CloudflareError
16
-
17
- _logger = logging.getLogger(__name__)
18
-
19
- DEFAULT_IMPERSONATE = "chrome136"
20
-
21
-
22
- class RetryPolicy:
23
- """
24
- Retry policy for transient errors (CloudflareError + curl_cffi network errors).
25
-
26
- :param max_attempts: total number of attempts including the first one (>= 1).
27
- :param base_delay: seconds for the first backoff sleep.
28
- :param max_delay: cap for the exponential backoff.
29
- :param jitter: random factor [0, jitter) added to each sleep.
30
- """
31
-
32
- def __init__(
33
- self,
34
- max_attempts: int = 1,
35
- base_delay: float = 1.0,
36
- max_delay: float = 30.0,
37
- jitter: float = 0.5,
38
- ):
39
- if max_attempts < 1:
40
- raise ValueError("max_attempts must be >= 1")
41
- if base_delay < 0 or max_delay < 0 or jitter < 0:
42
- raise ValueError("base_delay, max_delay and jitter must all be >= 0")
43
- self.max_attempts = max_attempts
44
- self.base_delay = base_delay
45
- self.max_delay = max_delay
46
- self.jitter = jitter
47
-
48
- def sleep_for(self, attempt_index: int) -> float:
49
- """Return the backoff (in seconds) for the given 0-based attempt index."""
50
- delay = min(self.base_delay * (2 ** attempt_index), self.max_delay)
51
- return delay + random.uniform(0, self.jitter)
52
-
53
-
54
- def _run_with_retry(fn, retry: Optional[RetryPolicy]):
55
- """Execute ``fn()`` with retry on CloudflareError / transient network errors."""
56
- if retry is None or retry.max_attempts <= 1:
57
- return fn()
58
-
59
- last_error: Optional[Exception] = None
60
- for attempt in range(retry.max_attempts):
61
- try:
62
- return fn()
63
- except CloudflareError as err:
64
- last_error = err
65
- except requests.errors.RequestsError as err: # type: ignore[attr-defined]
66
- last_error = err
67
- if attempt < retry.max_attempts - 1:
68
- time.sleep(retry.sleep_for(attempt))
69
- # Defensive: with max_attempts > 1, last_error is always populated by the
70
- # time we reach here, but don't rely on `assert` because it is stripped by
71
- # `python -O` and would turn into `raise None` -> TypeError.
72
- if last_error is None:
73
- raise RuntimeError("retry loop exited without success or captured error")
74
- raise last_error
75
-
76
-
77
- class APIClient:
78
- """
79
- Central HTTP client for the FlightRadarAPI package.
80
-
81
- Owns the persistent session (cookie jar, TLS fingerprint, future bypass logic)
82
- so that the rest of the codebase never has to deal with those concerns directly.
83
-
84
- :param impersonate: curl_cffi browser profile to mimic. Defaults to
85
- ``DEFAULT_IMPERSONATE`` (currently ``"chrome136"``). When FR24 updates its
86
- Cloudflare bot mitigation, pass a newer profile (e.g. ``"chrome137"``,
87
- ``"chrome138"``) without waiting for a library release.
88
- """
89
-
90
- def __init__(
91
- self,
92
- impersonate: str = DEFAULT_IMPERSONATE,
93
- retry: Optional[RetryPolicy] = None,
94
- ) -> None:
95
- self.__impersonate = impersonate
96
- self.__retry = retry
97
- self.__session: Session = Session(impersonate=impersonate) # type: ignore[arg-type]
98
-
99
- def request(self, url: str, **kwargs) -> "APIRequest":
100
- """Make a request through the shared session."""
101
- return _run_with_retry(
102
- lambda: APIRequest(url, session=self.__session, **kwargs),
103
- self.__retry,
104
- )
105
-
106
- def request_standalone(self, url: str, **kwargs) -> "APIRequest":
107
- """Make a stateless request with no shared session (safe to call from threads).
108
-
109
- The TLS impersonation profile is inherited from this client so that
110
- thread-pool fan-outs still mimic the same browser as the session.
111
- """
112
- return _run_with_retry(
113
- lambda: APIRequest(url, impersonate=self.__impersonate, **kwargs),
114
- self.__retry,
115
- )
116
-
117
- def get_cookie(self, name: str) -> Optional[str]:
118
- """Return the value of a stored cookie by name."""
119
- return self.__session.cookies.get(name)
120
-
121
- def clear_cookies(self) -> None:
122
- """Clear all cookies from the session."""
123
- self.__session.cookies.clear()
124
-
125
- def delete_cookie(self, name: str) -> None:
126
- """Drop a single cookie, leaving the rest of the jar intact.
127
-
128
- Sheds load-balancer stickiness without discarding the login session,
129
- which lives in the same jar.
130
- """
131
- try:
132
- del self.__session.cookies[name]
133
- except KeyError:
134
- pass
135
-
136
-
137
- class APIRequest:
138
- """
139
- Class to make requests to the FlightRadar24.
140
- """
141
- __content_encodings = {
142
- "": lambda x: x,
143
- "br": brotli.decompress,
144
- "gzip": gzip.decompress
145
- }
146
-
147
- def __init__(
148
- self,
149
- url: str,
150
- *,
151
- session: Optional[Session] = None,
152
- params: Optional[Dict] = None,
153
- headers: Optional[Dict] = None,
154
- timeout: int = 30,
155
- data: Optional[Dict] = None,
156
- allowed_error_codes: Optional[List[int]] = None,
157
- impersonate: str = DEFAULT_IMPERSONATE,
158
- ):
159
- """
160
- Constructor of the APIRequest class.
161
-
162
- :param url: URL for the request
163
- :param session: session to reuse across requests; handles cookies automatically
164
- :param params: params that will be inserted on the URL for the request
165
- :param headers: headers for the request
166
- :param data: data for the request. If "data" is None, request will be a GET. Otherwise, it will be a POST
167
- :param allowed_error_codes: status codes that should not raise an error
168
- :param impersonate: curl_cffi browser profile (only used when no session is provided)
169
- """
170
- self.url = url
171
-
172
- if params: url += "?" + urlencode(params)
173
-
174
- if session is not None:
175
- request_method = session.get if data is None else session.post
176
- self.__response = request_method(url, headers=headers, data=data, timeout=timeout)
177
- else:
178
- request_method = requests.get if data is None else requests.post
179
- self.__response = request_method(
180
- url, headers=headers, data=data, timeout=timeout,
181
- impersonate=impersonate # type: ignore[arg-type]
182
- )
183
-
184
- # Cloudflare detection only when the caller did not opt-in to this status code.
185
- # `getAirlineLogo`/`getCountryFlag` allow 403 to mean "asset not found" on the CDN.
186
- if (self.get_status_code() not in (allowed_error_codes or [])
187
- and self.__is_cloudflare_block()):
188
- raise CloudflareError(
189
- message="Blocked by Cloudflare. Perhaps you are making too many calls, "
190
- "or the TLS impersonation needs to be updated.",
191
- response=self.__response
192
- )
193
-
194
- if self.get_status_code() not in (allowed_error_codes or []):
195
- self.__response.raise_for_status()
196
-
197
- def __is_cloudflare_block(self) -> bool:
198
- """
199
- Detect Cloudflare-level blocks.
200
-
201
- FR24 fronts the public site with Cloudflare, so a `Server: cloudflare`
202
- header is present on *every* response — including legitimate 403s from
203
- the FR24 origin (e.g. premium-only endpoints accessed by a free
204
- account). To avoid false positives we rely on signals that Cloudflare
205
- sets only when its own bot-management / challenge actually took
206
- action:
207
-
208
- - HTTP 520 (Cloudflare's "unknown error from origin").
209
- - HTTP 403 with the `cf-mitigated` header set — Cloudflare adds this
210
- specifically when it (not the origin) decided to block the request.
211
- """
212
- status = self.get_status_code()
213
- if status == 520:
214
- return True
215
- if status != 403:
216
- return False
217
- return bool(self.__response.headers.get("cf-mitigated"))
218
-
219
- def get_content(self) -> Union[Dict, bytes]:
220
- """
221
- Return the received content from the request.
222
- """
223
- content = self.__response.content
224
-
225
- content_encoding = self.__response.headers.get("Content-Encoding", "")
226
- content_type = self.__response.headers.get("Content-Type", "")
227
-
228
- # Decompress the content if a known encoding was used; fall back to raw bytes otherwise.
229
- # curl_cffi may already decompress content automatically, so failures here usually mean
230
- # the bytes were already decoded by the transport layer — log and continue rather than
231
- # surfacing an error the caller cannot act on.
232
- decode = self.__content_encodings.get(content_encoding, self.__content_encodings[""])
233
- try:
234
- content = decode(content)
235
- except Exception as err:
236
- # Decided by the body, not the header: undecodable text is genuinely
237
- # broken and must warn, while binary bodies carry no such tell. Nothing
238
- # here may raise, or it would replace `err` with its own failure.
239
- if not isinstance(content, bytes):
240
- transport_decoded = True
241
- elif content_type.startswith(("application/json", "text/")):
242
- try:
243
- content.decode("utf-8")
244
- transport_decoded = True
245
- except UnicodeDecodeError:
246
- transport_decoded = False
247
- else:
248
- corrupt_gzip = content_encoding == "gzip" and content.startswith(b"\x1f\x8b")
249
- transport_decoded = content_encoding in ("gzip", "br") and not corrupt_gzip
250
-
251
- _logger.log(
252
- logging.DEBUG if transport_decoded else logging.WARNING,
253
- "APIRequest.get_content: failed to decode Content-Encoding=%r for %s (%s). "
254
- "Assuming the transport already decompressed and returning raw bytes.",
255
- content_encoding, self.url, err,
256
- )
257
-
258
- # Return a dictionary if the content type is JSON.
259
- if "application/json" in content_type:
260
- return json.loads(content)
261
-
262
- return content
263
-
264
- def get_json_content(self) -> Dict[str, Any]:
265
- """
266
- Return the response content as a parsed JSON dictionary.
267
- """
268
- content = self.get_content()
269
- if not isinstance(content, dict):
270
- raise ValueError(f"Expected JSON response from {self.url}, got bytes")
271
- return content
272
-
273
- def get_bytes_content(self) -> bytes:
274
- """
275
- Return the response content as raw bytes.
276
- """
277
- content = self.get_content()
278
- if not isinstance(content, bytes):
279
- raise ValueError(f"Expected bytes response from {self.url}, got JSON")
280
- return content
281
-
282
- def get_headers(self) -> Any:
283
- """
284
- Return the headers of the response.
285
- """
286
- return self.__response.headers
287
-
288
- def get_response_object(self) -> Any:
289
- """
290
- Return the received response object.
291
- """
292
- return self.__response
293
-
294
- def get_status_code(self) -> int:
295
- """
296
- Return the status code of the response.
297
- """
298
- return self.__response.status_code
File without changes
File without changes