|
7 | 7 | import warnings |
8 | 8 | import weakref |
9 | 9 | from collections import OrderedDict |
10 | | -from collections.abc import Mapping |
| 10 | +from collections.abc import Callable, Mapping |
11 | 11 | from datetime import datetime, timedelta, timezone |
12 | 12 | from functools import partial |
13 | 13 | from json import JSONDecodeError |
14 | 14 | from pathlib import Path |
| 15 | +from typing import Any |
15 | 16 | from urllib.parse import quote |
16 | 17 |
|
17 | | -import attr |
18 | 18 | import httpx2 |
19 | 19 | import outcome |
| 20 | +from attrs import define |
20 | 21 | from filelock import FileLock |
21 | 22 | from httpx2 import USE_CLIENT_DEFAULT |
22 | 23 | from logbook import Logger |
@@ -79,48 +80,47 @@ class Event: |
79 | 80 | pass |
80 | 81 |
|
81 | 82 |
|
82 | | -@attr.s(slots=True) |
| 83 | +@define |
83 | 84 | class NormalRequest(Event): |
84 | | - request = attr.ib() |
85 | | - stream = attr.ib(default=False) |
86 | | - follow_redirects = attr.ib(default=False) |
| 85 | + request: httpx2.Request |
| 86 | + stream: bool = False |
| 87 | + follow_redirects: bool = False |
87 | 88 |
|
88 | 89 |
|
89 | | -@attr.s(slots=True) |
| 90 | +@define |
90 | 91 | class ReadResponse(Event): |
91 | | - response = attr.ib() |
| 92 | + response: httpx2.Response |
92 | 93 |
|
93 | 94 |
|
94 | | -@attr.s(slots=True) |
| 95 | +@define |
95 | 96 | class IterLines(Event): |
96 | | - response = attr.ib() |
| 97 | + response: httpx2.Response |
97 | 98 |
|
98 | 99 |
|
99 | | -@attr.s(slots=True) |
| 100 | +@define |
100 | 101 | class IterContent(Event): |
101 | | - response = attr.ib() |
102 | | - decode = attr.ib() |
| 102 | + response: httpx2.Response |
| 103 | + decode: bool |
103 | 104 |
|
104 | 105 |
|
105 | | -@attr.s(slots=True) |
| 106 | +@define |
106 | 107 | class RateLimitWait(Event): |
107 | | - duration = attr.ib() |
| 108 | + duration: float |
108 | 109 |
|
109 | 110 |
|
110 | | -@attr.s(slots=True) |
111 | | -class AcquireLock(Event): |
112 | | - lock = attr.ib() |
| 111 | +@define |
| 112 | +class AcquireFileLock(Event): |
| 113 | + lock: FileLock |
113 | 114 |
|
114 | 115 |
|
115 | | -@attr.s(slots=True) |
116 | | -class ReleaseLock(Event): |
117 | | - lock = attr.ib() |
| 116 | +@define |
| 117 | +class ReleaseFileLock(Event): |
| 118 | + lock: FileLock |
118 | 119 |
|
119 | 120 |
|
120 | | -class UnsupportedAsyncLibrary(Exception): |
121 | | - """Raised internally when an event cannot be handled with the active async |
122 | | - library. |
123 | | - """ |
| 121 | +@define |
| 122 | +class RunBlocking(Event): |
| 123 | + func: Callable[[], Any] |
124 | 124 |
|
125 | 125 |
|
126 | 126 | class Predicate(ReprHelperMixin): |
@@ -299,7 +299,6 @@ class SpaceTrackClient: |
299 | 299 | Predicate("favorites", "str"), |
300 | 300 | } |
301 | 301 |
|
302 | | - _file_lock_cls = FileLock |
303 | 302 | _httpx_client_cls = httpx2.Client |
304 | 303 |
|
305 | 304 | def __init__( |
@@ -403,10 +402,12 @@ def _handle_event(self, event): |
403 | 402 | return _iter_content_generator(event.response, event.decode) |
404 | 403 | elif isinstance(event, RateLimitWait): |
405 | 404 | self._ratelimit_wait(event.duration) |
406 | | - elif isinstance(event, AcquireLock): |
| 405 | + elif isinstance(event, AcquireFileLock): |
407 | 406 | event.lock.acquire() |
408 | | - elif isinstance(event, ReleaseLock): |
| 407 | + elif isinstance(event, ReleaseFileLock): |
409 | 408 | event.lock.release() |
| 409 | + elif isinstance(event, RunBlocking): |
| 410 | + return event.func() |
410 | 411 | else: |
411 | 412 | raise RuntimeError(f"Unknown event type: {type(event)}") |
412 | 413 |
|
@@ -779,10 +780,20 @@ def _ratelimit_callback(self, until): |
779 | 780 |
|
780 | 781 | def _ratelimit_wait(self, duration): |
781 | 782 | until = time.monotonic() + duration |
782 | | - t = threading.Thread(target=self._ratelimit_callback, args=(until,)) |
| 783 | + callback_outcome = None |
| 784 | + |
| 785 | + def run_callback(): |
| 786 | + nonlocal callback_outcome |
| 787 | + callback_outcome = outcome.capture(self._ratelimit_callback, until) |
| 788 | + |
| 789 | + t = threading.Thread(target=run_callback) |
783 | 790 | t.daemon = True |
784 | 791 | t.start() |
785 | 792 | time.sleep(duration) |
| 793 | + t.join() |
| 794 | + # Match the async client, where a callback exception propagates and |
| 795 | + # aborts the request. |
| 796 | + callback_outcome.unwrap() |
786 | 797 |
|
787 | 798 | def __getattr__(self, attr): |
788 | 799 | if attr in self.request_controllers: |
@@ -867,41 +878,33 @@ def _get_predicates_generator(self, class_, controller, *, force=False): |
867 | 878 | hasher.update(key.encode()) |
868 | 879 | hashkey = hasher.hexdigest()[:16] |
869 | 880 | cache_file = self._cache_path / f"predicates-{hashkey}.json" |
870 | | - predicates_data = self._read_cache_file( |
871 | | - cache_file, PREDICATE_CACHE_EXPIRY_TIME |
| 881 | + read_cache = partial( |
| 882 | + self._read_cache_file, cache_file, PREDICATE_CACHE_EXPIRY_TIME |
872 | 883 | ) |
| 884 | + predicates_data = yield RunBlocking(read_cache) |
873 | 885 |
|
874 | 886 | if predicates_data is None: |
875 | | - self._cache_path.mkdir(parents=True, exist_ok=True) |
| 887 | + yield RunBlocking( |
| 888 | + partial(self._cache_path.mkdir, parents=True, exist_ok=True) |
| 889 | + ) |
876 | 890 |
|
877 | 891 | lock_file = cache_file.with_name(cache_file.name + ".lock") |
878 | | - lock = self._file_lock_cls(lock_file) |
879 | | - try: |
880 | | - yield AcquireLock(lock) |
881 | | - except UnsupportedAsyncLibrary: |
882 | | - if not force: |
883 | | - # The file lock doesn't support Trio, skip predicate |
884 | | - # checking by setting None |
885 | | - self._predicates[key] = None |
886 | | - return self._predicates[key] |
887 | | - lock_acquired = False |
888 | | - else: |
889 | | - lock_acquired = True |
| 892 | + # thread_local=False because the async client acquires and |
| 893 | + # releases the lock from different worker threads. |
| 894 | + lock = FileLock(lock_file, thread_local=False) |
| 895 | + yield AcquireFileLock(lock) |
890 | 896 |
|
891 | 897 | try: |
892 | | - if lock_acquired: |
893 | | - predicates_data = self._read_cache_file( |
894 | | - cache_file, PREDICATE_CACHE_EXPIRY_TIME |
895 | | - ) |
| 898 | + predicates_data = yield RunBlocking(read_cache) |
896 | 899 | if predicates_data is None: |
897 | 900 | predicates_data = yield from self._download_predicate_data_generator( |
898 | 901 | class_, controller |
899 | 902 | ) |
900 | | - if lock_acquired: |
901 | | - self._write_cache_file(cache_file, predicates_data) |
| 903 | + yield RunBlocking( |
| 904 | + partial(self._write_cache_file, cache_file, predicates_data) |
| 905 | + ) |
902 | 906 | finally: |
903 | | - if lock_acquired: |
904 | | - yield ReleaseLock(lock) |
| 907 | + yield ReleaseFileLock(lock) |
905 | 908 |
|
906 | 909 | predicate_objects = self._parse_predicates_data(predicates_data) |
907 | 910 |
|
|
0 commit comments