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/bin/ffl.com ADDED
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: ffl/bin/ffl.com
3
+ size: 51721850 bytes
4
+ sha256: 0c614cf8b87a1e73fde86b04911a246dd3ca9420819cfe8a05e8edc03146737b
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