ffl-python 0.1.4__py3-none-any.whl
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.
- ffl/__init__.py +63 -0
- ffl/_generated.py +1658 -0
- ffl/_runtime.py +1136 -0
- ffl/bin/ffl.com +4 -0
- ffl/client.py +328 -0
- ffl/errors.py +25 -0
- ffl/events.py +280 -0
- ffl/models.py +285 -0
- ffl/parsing.py +110 -0
- ffl/py.typed +1 -0
- ffl_python-0.1.4.dist-info/METADATA +210 -0
- ffl_python-0.1.4.dist-info/RECORD +15 -0
- ffl_python-0.1.4.dist-info/WHEEL +5 -0
- ffl_python-0.1.4.dist-info/licenses/LICENSE +201 -0
- ffl_python-0.1.4.dist-info/top_level.txt +1 -0
ffl/bin/ffl.com
ADDED
ffl/client.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
# FastFileLink CLI - Fast, no-fuss file sharing
|
|
6
|
+
# Copyright (C) 2025-2026 FastFileLink contributors
|
|
7
|
+
#
|
|
8
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
9
|
+
# you may not use this file except in compliance with the License.
|
|
10
|
+
# You may obtain a copy of the License at
|
|
11
|
+
#
|
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
#
|
|
14
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
15
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
16
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
17
|
+
# See the License for the specific language governing permissions and
|
|
18
|
+
# limitations under the License.
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import tempfile
|
|
23
|
+
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from typing import BinaryIO
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import _generated
|
|
30
|
+
from .events import FFLHookEventChannel
|
|
31
|
+
from .models import DownloadResult, DownloadSession, KeygenResult, ShareSession
|
|
32
|
+
from .parsing import FFLResultParser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FFLClient:
|
|
36
|
+
"""High-level FFL API built on the bundled subprocess layer."""
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _normalize_optional_value(value: str | bool | None) -> str | bool | None:
|
|
40
|
+
if value == '':
|
|
41
|
+
return True
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
def _share_temporary_bytes(
|
|
45
|
+
self,
|
|
46
|
+
data: bytes,
|
|
47
|
+
name: str,
|
|
48
|
+
share_options: dict[str, Any],
|
|
49
|
+
) -> ShareSession:
|
|
50
|
+
suffix = Path(name).suffix
|
|
51
|
+
temporary_file = tempfile.NamedTemporaryFile(
|
|
52
|
+
prefix='ffl-python-',
|
|
53
|
+
suffix=suffix,
|
|
54
|
+
delete=False,
|
|
55
|
+
)
|
|
56
|
+
temporary_path = Path(temporary_file.name)
|
|
57
|
+
try:
|
|
58
|
+
temporary_file.write(data)
|
|
59
|
+
temporary_file.close()
|
|
60
|
+
session = self.share(temporary_path, name=name, **share_options)
|
|
61
|
+
except Exception:
|
|
62
|
+
temporary_file.close()
|
|
63
|
+
temporary_path.unlink(missing_ok=True)
|
|
64
|
+
raise
|
|
65
|
+
return session.attach_cleanup_paths((temporary_path,))
|
|
66
|
+
|
|
67
|
+
def version(self) -> str:
|
|
68
|
+
return _generated.version(version=True)
|
|
69
|
+
|
|
70
|
+
def share(
|
|
71
|
+
self,
|
|
72
|
+
paths: str | Path | Iterable[str | Path],
|
|
73
|
+
*,
|
|
74
|
+
name: str | None = None,
|
|
75
|
+
e2ee: bool = False,
|
|
76
|
+
auth_user: str | None = None,
|
|
77
|
+
auth_password: str | None = None,
|
|
78
|
+
max_downloads: int | None = None,
|
|
79
|
+
timeout_seconds: int | None = None,
|
|
80
|
+
hook_url: str | None = None,
|
|
81
|
+
capture_hook_events: bool = True,
|
|
82
|
+
event_history_limit: int = 1000,
|
|
83
|
+
proxy: str | None = None,
|
|
84
|
+
exclude: str | None = None,
|
|
85
|
+
recipient_auth: str | None = None,
|
|
86
|
+
pickup_code: str | None = None,
|
|
87
|
+
recipient_public_key: str | Path | None = None,
|
|
88
|
+
recipient_email: str | None = None,
|
|
89
|
+
recipient_otp_api_base: str | None = None,
|
|
90
|
+
alias: str | None = None,
|
|
91
|
+
receipt: str | bool | None = None,
|
|
92
|
+
receipt_confirm: str | bool | None = None,
|
|
93
|
+
force_relay: bool = False,
|
|
94
|
+
upload: str | bool | None = None,
|
|
95
|
+
resume_upload: bool = False,
|
|
96
|
+
vfs: bool = False,
|
|
97
|
+
preferred_tunnel: str | None = None,
|
|
98
|
+
port: int | None = None,
|
|
99
|
+
invite: bool = False,
|
|
100
|
+
pause: int | None = None,
|
|
101
|
+
enable_reporting: bool = False,
|
|
102
|
+
qr: str | Path | bool | None = None,
|
|
103
|
+
stdin_cache: str | None = None,
|
|
104
|
+
log_level: str | None = None,
|
|
105
|
+
stdin: BinaryIO | None = None,
|
|
106
|
+
) -> ShareSession:
|
|
107
|
+
event_channel = (
|
|
108
|
+
FFLHookEventChannel(hook_url, event_history_limit)
|
|
109
|
+
if capture_hook_events
|
|
110
|
+
else None
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
session = _generated.share(
|
|
114
|
+
paths=paths,
|
|
115
|
+
log_level=log_level,
|
|
116
|
+
proxy=proxy,
|
|
117
|
+
hook=hook_url if event_channel is None else event_channel.url,
|
|
118
|
+
enable_reporting=enable_reporting,
|
|
119
|
+
name=name,
|
|
120
|
+
exclude=exclude,
|
|
121
|
+
upload=self._normalize_optional_value(upload),
|
|
122
|
+
resume=resume_upload,
|
|
123
|
+
pause=pause,
|
|
124
|
+
max_downloads=max_downloads,
|
|
125
|
+
timeout=timeout_seconds,
|
|
126
|
+
port=port,
|
|
127
|
+
auth_user=auth_user,
|
|
128
|
+
auth_password=auth_password,
|
|
129
|
+
recipient_auth=recipient_auth,
|
|
130
|
+
pickup_code=pickup_code,
|
|
131
|
+
recipient_public_key=recipient_public_key,
|
|
132
|
+
recipient_email=recipient_email,
|
|
133
|
+
recipient_otp_api_base=recipient_otp_api_base,
|
|
134
|
+
force_relay=force_relay,
|
|
135
|
+
e2ee=e2ee,
|
|
136
|
+
invite=invite,
|
|
137
|
+
qr=self._normalize_optional_value(qr),
|
|
138
|
+
disable_clipboard=True,
|
|
139
|
+
vfs=vfs,
|
|
140
|
+
stdin_cache=stdin_cache,
|
|
141
|
+
foreground=True,
|
|
142
|
+
alias=alias,
|
|
143
|
+
receipt=self._normalize_optional_value(receipt),
|
|
144
|
+
receipt_confirm=self._normalize_optional_value(receipt_confirm),
|
|
145
|
+
preferred_tunnel=preferred_tunnel,
|
|
146
|
+
stdin=stdin,
|
|
147
|
+
)
|
|
148
|
+
except Exception:
|
|
149
|
+
if event_channel is not None:
|
|
150
|
+
event_channel.close()
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
return ShareSession(session, event_channel=event_channel)
|
|
154
|
+
|
|
155
|
+
def share_text(
|
|
156
|
+
self,
|
|
157
|
+
text: str,
|
|
158
|
+
name: str = 'shared.txt',
|
|
159
|
+
**share_options: Any,
|
|
160
|
+
) -> ShareSession:
|
|
161
|
+
return self._share_temporary_bytes(
|
|
162
|
+
text.encode('utf-8'),
|
|
163
|
+
name,
|
|
164
|
+
share_options,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def share_bytes(
|
|
168
|
+
self,
|
|
169
|
+
data: bytes,
|
|
170
|
+
name: str = 'data.bin',
|
|
171
|
+
**share_options: Any,
|
|
172
|
+
) -> ShareSession:
|
|
173
|
+
return self._share_temporary_bytes(data, name, share_options)
|
|
174
|
+
|
|
175
|
+
def share_stream(
|
|
176
|
+
self,
|
|
177
|
+
source: BinaryIO,
|
|
178
|
+
name: str = 'stream.bin',
|
|
179
|
+
**share_options: Any,
|
|
180
|
+
) -> ShareSession:
|
|
181
|
+
return self.share(
|
|
182
|
+
'-',
|
|
183
|
+
name=name,
|
|
184
|
+
stdin=source,
|
|
185
|
+
stdin_cache=share_options.pop('stdin_cache', 'off'),
|
|
186
|
+
**share_options,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def download(
|
|
190
|
+
self,
|
|
191
|
+
url: str,
|
|
192
|
+
*,
|
|
193
|
+
output_path: str | Path | None = None,
|
|
194
|
+
resume: bool = False,
|
|
195
|
+
auth_user: str | None = None,
|
|
196
|
+
auth_password: str | None = None,
|
|
197
|
+
proxy: str | None = None,
|
|
198
|
+
recipient_auth: str | None = None,
|
|
199
|
+
pickup_code: str | None = None,
|
|
200
|
+
recipient_private_key: str | Path | None = None,
|
|
201
|
+
enable_reporting: bool = False,
|
|
202
|
+
hook_url: str | None = None,
|
|
203
|
+
log_level: str | None = None,
|
|
204
|
+
) -> DownloadResult:
|
|
205
|
+
return self.start_download(
|
|
206
|
+
url,
|
|
207
|
+
output_path=output_path,
|
|
208
|
+
resume=resume,
|
|
209
|
+
auth_user=auth_user,
|
|
210
|
+
auth_password=auth_password,
|
|
211
|
+
proxy=proxy,
|
|
212
|
+
recipient_auth=recipient_auth,
|
|
213
|
+
pickup_code=pickup_code,
|
|
214
|
+
recipient_private_key=recipient_private_key,
|
|
215
|
+
enable_reporting=enable_reporting,
|
|
216
|
+
hook_url=hook_url,
|
|
217
|
+
log_level=log_level,
|
|
218
|
+
).wait()
|
|
219
|
+
|
|
220
|
+
def start_download(
|
|
221
|
+
self,
|
|
222
|
+
url: str,
|
|
223
|
+
*,
|
|
224
|
+
output_path: str | Path | None = None,
|
|
225
|
+
resume: bool = False,
|
|
226
|
+
auth_user: str | None = None,
|
|
227
|
+
auth_password: str | None = None,
|
|
228
|
+
proxy: str | None = None,
|
|
229
|
+
recipient_auth: str | None = None,
|
|
230
|
+
pickup_code: str | None = None,
|
|
231
|
+
recipient_private_key: str | Path | None = None,
|
|
232
|
+
enable_reporting: bool = False,
|
|
233
|
+
hook_url: str | None = None,
|
|
234
|
+
log_level: str | None = None,
|
|
235
|
+
) -> DownloadSession:
|
|
236
|
+
return self._start_download(
|
|
237
|
+
url,
|
|
238
|
+
output_path=output_path,
|
|
239
|
+
resume=resume,
|
|
240
|
+
auth_user=auth_user,
|
|
241
|
+
auth_password=auth_password,
|
|
242
|
+
proxy=proxy,
|
|
243
|
+
recipient_auth=recipient_auth,
|
|
244
|
+
pickup_code=pickup_code,
|
|
245
|
+
recipient_private_key=recipient_private_key,
|
|
246
|
+
enable_reporting=enable_reporting,
|
|
247
|
+
hook_url=hook_url,
|
|
248
|
+
log_level=log_level,
|
|
249
|
+
stream_stdout=False,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _start_download(
|
|
253
|
+
self,
|
|
254
|
+
url: str,
|
|
255
|
+
*,
|
|
256
|
+
output_path: str | Path | None = None,
|
|
257
|
+
resume: bool = False,
|
|
258
|
+
auth_user: str | None = None,
|
|
259
|
+
auth_password: str | None = None,
|
|
260
|
+
proxy: str | None = None,
|
|
261
|
+
recipient_auth: str | None = None,
|
|
262
|
+
pickup_code: str | None = None,
|
|
263
|
+
recipient_private_key: str | Path | None = None,
|
|
264
|
+
enable_reporting: bool = False,
|
|
265
|
+
hook_url: str | None = None,
|
|
266
|
+
log_level: str | None = None,
|
|
267
|
+
stream_stdout: bool,
|
|
268
|
+
) -> DownloadSession:
|
|
269
|
+
cwd = Path.cwd()
|
|
270
|
+
process = _generated.download(
|
|
271
|
+
url=url,
|
|
272
|
+
log_level=log_level,
|
|
273
|
+
proxy=proxy,
|
|
274
|
+
hook=hook_url,
|
|
275
|
+
enable_reporting=enable_reporting,
|
|
276
|
+
output_path=output_path,
|
|
277
|
+
resume=resume,
|
|
278
|
+
auth_user=auth_user,
|
|
279
|
+
auth_password=auth_password,
|
|
280
|
+
recipient_auth=recipient_auth,
|
|
281
|
+
pickup_code=pickup_code,
|
|
282
|
+
recipient_private_key=recipient_private_key,
|
|
283
|
+
stdout=stream_stdout,
|
|
284
|
+
_apebind_capture_stdout=not stream_stdout,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
session = DownloadSession(
|
|
288
|
+
process,
|
|
289
|
+
output_path,
|
|
290
|
+
lambda result, requested_path: FFLResultParser.parse_download(
|
|
291
|
+
result,
|
|
292
|
+
requested_path,
|
|
293
|
+
cwd,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
return session
|
|
297
|
+
|
|
298
|
+
def download_stream(
|
|
299
|
+
self,
|
|
300
|
+
url: str,
|
|
301
|
+
**download_options: Any,
|
|
302
|
+
) -> DownloadSession:
|
|
303
|
+
if 'output_path' in download_options:
|
|
304
|
+
raise TypeError('download_stream writes to stdout and does not accept output_path')
|
|
305
|
+
|
|
306
|
+
return self._start_download(
|
|
307
|
+
url,
|
|
308
|
+
stream_stdout=True,
|
|
309
|
+
**download_options,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def keygen(
|
|
313
|
+
self,
|
|
314
|
+
name: str | None = None,
|
|
315
|
+
*,
|
|
316
|
+
enable_reporting: bool = False,
|
|
317
|
+
log_level: str | None = None,
|
|
318
|
+
) -> KeygenResult:
|
|
319
|
+
cwd = Path.cwd()
|
|
320
|
+
process = _generated.keygen(
|
|
321
|
+
name=name,
|
|
322
|
+
enable_reporting=enable_reporting,
|
|
323
|
+
log_level=log_level,
|
|
324
|
+
)
|
|
325
|
+
return FFLResultParser.parse_keygen(process, cwd)
|
|
326
|
+
|
|
327
|
+
def raw(self, arguments: list[str], persistent: bool = False):
|
|
328
|
+
return _generated.raw(arguments, persistent=persistent)
|
ffl/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
# FastFileLink CLI - Fast, no-fuss file sharing
|
|
6
|
+
# Copyright (C) 2025-2026 FastFileLink contributors
|
|
7
|
+
#
|
|
8
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
9
|
+
# you may not use this file except in compliance with the License.
|
|
10
|
+
# You may obtain a copy of the License at
|
|
11
|
+
#
|
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
#
|
|
14
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
15
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
16
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
17
|
+
# See the License for the specific language governing permissions and
|
|
18
|
+
# limitations under the License.
|
|
19
|
+
|
|
20
|
+
class FFLOutputError(RuntimeError):
|
|
21
|
+
"""Raised when a successful FFL command produces an unexpected output shape."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FFLDownloadAbortedError(RuntimeError):
|
|
25
|
+
"""Raised when a caller aborts a foreground FFL download."""
|
ffl/events.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
# FastFileLink CLI - Fast, no-fuss file sharing
|
|
6
|
+
# Copyright (C) 2025-2026 FastFileLink contributors
|
|
7
|
+
#
|
|
8
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
9
|
+
# you may not use this file except in compliance with the License.
|
|
10
|
+
# You may obtain a copy of the License at
|
|
11
|
+
#
|
|
12
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
13
|
+
#
|
|
14
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
15
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
16
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
17
|
+
# See the License for the specific language governing permissions and
|
|
18
|
+
# limitations under the License.
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import queue
|
|
24
|
+
import threading
|
|
25
|
+
|
|
26
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.error import URLError
|
|
31
|
+
from urllib.request import Request, urlopen
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_SEMANTIC_EVENT_NAMES = {
|
|
35
|
+
'/hook/server/endpoints/register': 'ready',
|
|
36
|
+
'/hook/transfer/progress': 'progress',
|
|
37
|
+
'/hook/transfer/transport': 'transport',
|
|
38
|
+
'/hook/transfer/complete': 'completed',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class FFLHookEvent:
|
|
44
|
+
"""An event emitted by FFL's HTTP hook transport."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
timestamp: str | None
|
|
48
|
+
data: Mapping[str, Any]
|
|
49
|
+
raw: Mapping[str, Any]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def semantic_name(self) -> str | None:
|
|
53
|
+
return _SEMANTIC_EVENT_NAMES.get(self.name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FFLHookEventChannel:
|
|
57
|
+
"""Own a local FFL hook endpoint and project its JSON payloads as events."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
forward_url: str | None = None,
|
|
62
|
+
event_history_limit: int = 1000,
|
|
63
|
+
):
|
|
64
|
+
if isinstance(event_history_limit, bool) or not isinstance(event_history_limit, int):
|
|
65
|
+
raise TypeError('event_history_limit must be an integer')
|
|
66
|
+
|
|
67
|
+
if event_history_limit < 1:
|
|
68
|
+
raise ValueError('event_history_limit must be a positive integer')
|
|
69
|
+
|
|
70
|
+
self._forward_url = forward_url
|
|
71
|
+
self._event_history_limit = event_history_limit
|
|
72
|
+
self._history: list[FFLHookEvent] = []
|
|
73
|
+
self._listeners: dict[str, list[Callable[[FFLHookEvent], None]]] = {}
|
|
74
|
+
self._semantic_listeners: dict[
|
|
75
|
+
str,
|
|
76
|
+
list[Callable[[FFLHookEvent], None]],
|
|
77
|
+
] = {}
|
|
78
|
+
self._iterators: set[queue.Queue[FFLHookEvent | None]] = set()
|
|
79
|
+
self._listener_queue: queue.Queue[
|
|
80
|
+
tuple[Callable[[FFLHookEvent], None], FFLHookEvent] | None
|
|
81
|
+
] = queue.Queue()
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._error: Exception | None = None
|
|
84
|
+
self._closed = False
|
|
85
|
+
|
|
86
|
+
self._server = ThreadingHTTPServer(('127.0.0.1', 0), self._handler())
|
|
87
|
+
self._thread = threading.Thread(
|
|
88
|
+
target=self._server.serve_forever,
|
|
89
|
+
name='ffl-python-hook',
|
|
90
|
+
daemon=True,
|
|
91
|
+
)
|
|
92
|
+
self._listener_thread = threading.Thread(
|
|
93
|
+
target=self._dispatch_listeners,
|
|
94
|
+
name='ffl-python-hook-listeners',
|
|
95
|
+
daemon=True,
|
|
96
|
+
)
|
|
97
|
+
self._listener_thread.start()
|
|
98
|
+
self._thread.start()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def url(self) -> str:
|
|
102
|
+
return f'http://127.0.0.1:{self._server.server_port}/events'
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def history(self) -> tuple[FFLHookEvent, ...]:
|
|
106
|
+
with self._lock:
|
|
107
|
+
return tuple(self._history)
|
|
108
|
+
|
|
109
|
+
def on(self, name: str, listener: Callable[[FFLHookEvent], None]) -> None:
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._listeners.setdefault(name, []).append(listener)
|
|
112
|
+
replay = tuple(event for event in self._history if event.name == name)
|
|
113
|
+
|
|
114
|
+
for event in replay:
|
|
115
|
+
self._call_listener(listener, event)
|
|
116
|
+
|
|
117
|
+
def on_semantic(self, name: str, listener: Callable[[FFLHookEvent], None]) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._semantic_listeners.setdefault(name, []).append(listener)
|
|
120
|
+
replay = tuple(
|
|
121
|
+
event for event in self._history if event.semantic_name == name
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for event in replay:
|
|
125
|
+
self._call_listener(listener, event)
|
|
126
|
+
|
|
127
|
+
def events(self) -> Iterator[FFLHookEvent]:
|
|
128
|
+
event_queue: queue.Queue[FFLHookEvent | None] = queue.Queue()
|
|
129
|
+
with self._lock:
|
|
130
|
+
for event in self._history:
|
|
131
|
+
event_queue.put(event)
|
|
132
|
+
|
|
133
|
+
if not self._closed:
|
|
134
|
+
self._iterators.add(event_queue)
|
|
135
|
+
else:
|
|
136
|
+
event_queue.put(None)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
while True:
|
|
140
|
+
event = event_queue.get()
|
|
141
|
+
if event is None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
yield event
|
|
145
|
+
finally:
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._iterators.discard(event_queue)
|
|
148
|
+
|
|
149
|
+
def raise_if_error(self) -> None:
|
|
150
|
+
if self._error is not None:
|
|
151
|
+
raise RuntimeError('FFL hook event handling failed') from self._error
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._closed:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self._closed = True
|
|
159
|
+
iterator_queues = tuple(self._iterators)
|
|
160
|
+
|
|
161
|
+
self._server.shutdown()
|
|
162
|
+
self._server.server_close()
|
|
163
|
+
self._thread.join(timeout=5)
|
|
164
|
+
self._listener_queue.put(None)
|
|
165
|
+
for event_queue in iterator_queues:
|
|
166
|
+
event_queue.put(None)
|
|
167
|
+
|
|
168
|
+
def _handler(self):
|
|
169
|
+
channel = self
|
|
170
|
+
|
|
171
|
+
class Handler(BaseHTTPRequestHandler):
|
|
172
|
+
def do_POST(self) -> None:
|
|
173
|
+
content_length = int(self.headers.get('Content-Length', '0'))
|
|
174
|
+
body = self.rfile.read(content_length)
|
|
175
|
+
published = False
|
|
176
|
+
try:
|
|
177
|
+
channel._publish(body)
|
|
178
|
+
published = True
|
|
179
|
+
self.send_response(204)
|
|
180
|
+
except Exception as error:
|
|
181
|
+
channel._record_error(error)
|
|
182
|
+
self.send_response(500)
|
|
183
|
+
|
|
184
|
+
self.end_headers()
|
|
185
|
+
|
|
186
|
+
if published and channel._forward_url is not None:
|
|
187
|
+
channel._forward_async(body)
|
|
188
|
+
|
|
189
|
+
def log_message(self, format_string: str, *args) -> None:
|
|
190
|
+
del format_string, args
|
|
191
|
+
|
|
192
|
+
return Handler
|
|
193
|
+
|
|
194
|
+
def _publish(self, body: bytes) -> None:
|
|
195
|
+
raw = json.loads(body.decode('utf-8'))
|
|
196
|
+
if not isinstance(raw, dict):
|
|
197
|
+
raise TypeError('FFL hook payload must be a JSON object')
|
|
198
|
+
|
|
199
|
+
name = raw.get('event')
|
|
200
|
+
if not isinstance(name, str) or not name:
|
|
201
|
+
raise ValueError('FFL hook payload is missing its event name')
|
|
202
|
+
|
|
203
|
+
timestamp = raw.get('timestamp')
|
|
204
|
+
if timestamp is not None and not isinstance(timestamp, str):
|
|
205
|
+
raise TypeError('FFL hook event timestamp must be a string')
|
|
206
|
+
|
|
207
|
+
data = raw.get('data', {})
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
raise TypeError('FFL hook event data must be a JSON object')
|
|
210
|
+
|
|
211
|
+
event = FFLHookEvent(name, timestamp, data, raw)
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._history.append(event)
|
|
214
|
+
if len(self._history) > self._event_history_limit:
|
|
215
|
+
self._history.pop(0)
|
|
216
|
+
|
|
217
|
+
iterator_queues = tuple(self._iterators)
|
|
218
|
+
listeners = tuple(self._listeners.get(name, ()))
|
|
219
|
+
semantic_listeners = tuple(
|
|
220
|
+
self._semantic_listeners.get(event.semantic_name, ())
|
|
221
|
+
)
|
|
222
|
+
self._enqueue_listeners(listeners, (event,))
|
|
223
|
+
self._enqueue_listeners(semantic_listeners, (event,))
|
|
224
|
+
|
|
225
|
+
for event_queue in iterator_queues:
|
|
226
|
+
event_queue.put(event)
|
|
227
|
+
|
|
228
|
+
def _enqueue_listeners(
|
|
229
|
+
self,
|
|
230
|
+
listeners: tuple[Callable[[FFLHookEvent], None], ...],
|
|
231
|
+
events: tuple[FFLHookEvent, ...],
|
|
232
|
+
) -> None:
|
|
233
|
+
for event in events:
|
|
234
|
+
for listener in listeners:
|
|
235
|
+
self._listener_queue.put((listener, event))
|
|
236
|
+
|
|
237
|
+
def _dispatch_listeners(self) -> None:
|
|
238
|
+
while True:
|
|
239
|
+
item = self._listener_queue.get()
|
|
240
|
+
if item is None:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
listener, event = item
|
|
244
|
+
self._call_listener(listener, event)
|
|
245
|
+
|
|
246
|
+
def _forward_async(self, body: bytes) -> None:
|
|
247
|
+
threading.Thread(
|
|
248
|
+
target=self._forward,
|
|
249
|
+
args=(body,),
|
|
250
|
+
name='ffl-python-hook-forward',
|
|
251
|
+
daemon=True,
|
|
252
|
+
).start()
|
|
253
|
+
|
|
254
|
+
def _forward(self, body: bytes) -> None:
|
|
255
|
+
request = Request(
|
|
256
|
+
self._forward_url,
|
|
257
|
+
data=body,
|
|
258
|
+
headers={'Content-Type': 'application/json'},
|
|
259
|
+
method='POST',
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
with urlopen(request, timeout=3):
|
|
263
|
+
pass
|
|
264
|
+
except (TimeoutError, URLError):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def _call_listener(
|
|
268
|
+
self,
|
|
269
|
+
listener: Callable[[FFLHookEvent], None],
|
|
270
|
+
event: FFLHookEvent,
|
|
271
|
+
) -> None:
|
|
272
|
+
try:
|
|
273
|
+
listener(event)
|
|
274
|
+
except Exception as error:
|
|
275
|
+
self._record_error(error)
|
|
276
|
+
|
|
277
|
+
def _record_error(self, error: Exception) -> None:
|
|
278
|
+
with self._lock:
|
|
279
|
+
if self._error is None:
|
|
280
|
+
self._error = error
|