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/models.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
from enum import IntEnum
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Iterator
|
|
26
|
+
|
|
27
|
+
from ._runtime import APEProcessResult, ProcessSession
|
|
28
|
+
from .errors import FFLDownloadAbortedError
|
|
29
|
+
from .events import FFLHookEvent, FFLHookEventChannel
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TransferMode(IntEnum):
|
|
33
|
+
UNKNOWN = 1
|
|
34
|
+
WEBRTC_P2P = 2
|
|
35
|
+
HTTP_FALLBACK = 3
|
|
36
|
+
HTTP_DIRECT = 4
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ShareSession:
|
|
40
|
+
"""Own a foreground FFL share process and any temporary source files."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
session: ProcessSession,
|
|
45
|
+
cleanup_paths: tuple[Path, ...] = (),
|
|
46
|
+
event_channel: FFLHookEventChannel | None = None,
|
|
47
|
+
):
|
|
48
|
+
self._session = session
|
|
49
|
+
self._cleanup_paths = cleanup_paths
|
|
50
|
+
self._event_channel = event_channel
|
|
51
|
+
self._session.on_exit(self._cleanup)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def argv(self) -> tuple[str, ...]:
|
|
55
|
+
return self._session.argv
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def link(self) -> str:
|
|
59
|
+
link = self._session.result
|
|
60
|
+
if not isinstance(link, str) or not link:
|
|
61
|
+
raise RuntimeError('FFL share session does not have a valid link result')
|
|
62
|
+
|
|
63
|
+
return link
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def pid(self) -> int:
|
|
67
|
+
return self._session.pid
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def running(self) -> bool:
|
|
71
|
+
return self._session.running
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def return_code(self) -> int | None:
|
|
75
|
+
return self._session.return_code
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def stdout(self):
|
|
79
|
+
return self._session.stdout
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def stderr(self):
|
|
83
|
+
return self._session.stderr
|
|
84
|
+
|
|
85
|
+
def _cleanup(self) -> None:
|
|
86
|
+
for path in self._cleanup_paths:
|
|
87
|
+
path.unlink(missing_ok=True)
|
|
88
|
+
|
|
89
|
+
self._cleanup_paths = ()
|
|
90
|
+
|
|
91
|
+
if self._event_channel is not None:
|
|
92
|
+
self._event_channel.close()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def event_history(self) -> tuple[FFLHookEvent, ...]:
|
|
96
|
+
if self._event_channel is None:
|
|
97
|
+
return ()
|
|
98
|
+
|
|
99
|
+
return self._event_channel.history
|
|
100
|
+
|
|
101
|
+
def on(self, name: str, listener: Callable[[FFLHookEvent], None]) -> None:
|
|
102
|
+
if self._event_channel is None:
|
|
103
|
+
raise RuntimeError('FFL share session does not have an event channel')
|
|
104
|
+
|
|
105
|
+
self._event_channel.on_semantic(name, listener)
|
|
106
|
+
|
|
107
|
+
def on_raw(self, name: str, listener: Callable[[FFLHookEvent], None]) -> None:
|
|
108
|
+
if self._event_channel is None:
|
|
109
|
+
raise RuntimeError('FFL share session does not have an event channel')
|
|
110
|
+
|
|
111
|
+
self._event_channel.on(name, listener)
|
|
112
|
+
|
|
113
|
+
def events(self) -> Iterator[FFLHookEvent]:
|
|
114
|
+
if self._event_channel is None:
|
|
115
|
+
return iter(())
|
|
116
|
+
|
|
117
|
+
return self._event_channel.events()
|
|
118
|
+
|
|
119
|
+
def raw_events(self) -> Iterator[FFLHookEvent]:
|
|
120
|
+
return self.events()
|
|
121
|
+
|
|
122
|
+
def _raise_event_error(self) -> None:
|
|
123
|
+
if self._event_channel is not None:
|
|
124
|
+
self._event_channel.raise_if_error()
|
|
125
|
+
|
|
126
|
+
def attach_cleanup_paths(self, paths: tuple[Path, ...]) -> ShareSession:
|
|
127
|
+
self._cleanup_paths = (*self._cleanup_paths, *paths)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def iter_stdout(self) -> Iterator[str]:
|
|
131
|
+
yield from self._session.iter_stdout()
|
|
132
|
+
|
|
133
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
134
|
+
try:
|
|
135
|
+
result = self._session.wait(timeout=timeout)
|
|
136
|
+
self._raise_event_error()
|
|
137
|
+
return result
|
|
138
|
+
finally:
|
|
139
|
+
if not self._session.running:
|
|
140
|
+
self._cleanup()
|
|
141
|
+
|
|
142
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
143
|
+
try:
|
|
144
|
+
self._session.stop(timeout=timeout)
|
|
145
|
+
self._raise_event_error()
|
|
146
|
+
finally:
|
|
147
|
+
self._cleanup()
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
try:
|
|
151
|
+
self._session.close()
|
|
152
|
+
self._raise_event_error()
|
|
153
|
+
finally:
|
|
154
|
+
self._cleanup()
|
|
155
|
+
|
|
156
|
+
def __enter__(self):
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, exception_type, exception_value, traceback_value) -> None:
|
|
160
|
+
del exception_type, exception_value, traceback_value
|
|
161
|
+
self.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DownloadResult:
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
process: APEProcessResult,
|
|
168
|
+
output_path: Path | None,
|
|
169
|
+
transfer_mode: TransferMode,
|
|
170
|
+
):
|
|
171
|
+
self.process = process
|
|
172
|
+
self.output_path = output_path
|
|
173
|
+
self.transfer_mode = transfer_mode
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def argv(self) -> tuple[str, ...]:
|
|
177
|
+
return self.process.argv
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def return_code(self) -> int:
|
|
181
|
+
return self.process.return_code
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def stdout(self) -> str:
|
|
185
|
+
return self.process.stdout
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def stderr(self) -> str:
|
|
189
|
+
return self.process.stderr
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class DownloadSession:
|
|
193
|
+
"""Own a foreground FFL download process until it completes or is stopped."""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
session: ProcessSession,
|
|
198
|
+
output_path: str | Path | None,
|
|
199
|
+
parse_result: Callable[[APEProcessResult, str | Path | None], DownloadResult],
|
|
200
|
+
):
|
|
201
|
+
self._session = session
|
|
202
|
+
self._output_path = output_path
|
|
203
|
+
self._parse_result = parse_result
|
|
204
|
+
self._abort_error: FFLDownloadAbortedError | None = None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def argv(self) -> tuple[str, ...]:
|
|
208
|
+
return self._session.argv
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def pid(self) -> int:
|
|
212
|
+
return self._session.pid
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def running(self) -> bool:
|
|
216
|
+
return self._session.running
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def return_code(self) -> int | None:
|
|
220
|
+
return self._session.return_code
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def cancelled(self) -> bool:
|
|
224
|
+
return self._abort_error is not None
|
|
225
|
+
|
|
226
|
+
def wait(self, timeout: float | None = None) -> DownloadResult:
|
|
227
|
+
self._session.wait(timeout)
|
|
228
|
+
if self._abort_error is not None:
|
|
229
|
+
raise self._abort_error
|
|
230
|
+
|
|
231
|
+
return self._parse_result(self._session.process_result, self._output_path)
|
|
232
|
+
|
|
233
|
+
def iter_bytes(self) -> Iterator[bytes]:
|
|
234
|
+
yield from self._session.iter_stdout_bytes()
|
|
235
|
+
|
|
236
|
+
def abort(self, timeout: float = 5.0) -> None:
|
|
237
|
+
if self._abort_error is None:
|
|
238
|
+
self._abort_error = FFLDownloadAbortedError('FFL download was aborted')
|
|
239
|
+
|
|
240
|
+
self._session.stop(timeout)
|
|
241
|
+
|
|
242
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
243
|
+
self.abort(timeout)
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
if self.running:
|
|
247
|
+
self.abort()
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
self._session.close()
|
|
251
|
+
|
|
252
|
+
def __enter__(self):
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __exit__(self, exception_type, exception_value, traceback_value) -> None:
|
|
256
|
+
del exception_type, exception_value, traceback_value
|
|
257
|
+
self.close()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class KeygenResult:
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
process: APEProcessResult,
|
|
264
|
+
private_key_path: Path,
|
|
265
|
+
public_key_path: Path,
|
|
266
|
+
):
|
|
267
|
+
self.process = process
|
|
268
|
+
self.private_key_path = private_key_path
|
|
269
|
+
self.public_key_path = public_key_path
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def argv(self) -> tuple[str, ...]:
|
|
273
|
+
return self.process.argv
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def return_code(self) -> int:
|
|
277
|
+
return self.process.return_code
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def stdout(self) -> str:
|
|
281
|
+
return self.process.stdout
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def stderr(self) -> str:
|
|
285
|
+
return self.process.stderr
|
ffl/parsing.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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 re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from ._runtime import APEProcessResult
|
|
26
|
+
from .errors import FFLOutputError
|
|
27
|
+
from .models import DownloadResult, KeygenResult, TransferMode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FFLResultParser:
|
|
31
|
+
_DOWNLOADED_RE = re.compile(r'^Downloaded:\s+(.+)$', re.MULTILINE)
|
|
32
|
+
_DOWNLOADING_RE = re.compile(r'Downloading\s+(.+?)\s+\(')
|
|
33
|
+
_PRIVATE_KEY_RE = re.compile(r'^\s*Private key\s*:\s*(.+?)\s*$', re.MULTILINE)
|
|
34
|
+
_PUBLIC_KEY_RE = re.compile(
|
|
35
|
+
r'^\s*Public key\s*:\s*(.+?)(?:\s+←.*)?$',
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _combined_output(process: APEProcessResult) -> str:
|
|
41
|
+
return '\n'.join(part for part in (process.stdout, process.stderr) if part)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _detect_transfer_mode(cls, output: str) -> TransferMode:
|
|
45
|
+
if (
|
|
46
|
+
'P2P direct' in output
|
|
47
|
+
or 'P2P TCP' in output
|
|
48
|
+
or 'WebRTC P2P' in output
|
|
49
|
+
):
|
|
50
|
+
return TransferMode.WEBRTC_P2P
|
|
51
|
+
if 'HTTP fallback' in output:
|
|
52
|
+
return TransferMode.HTTP_FALLBACK
|
|
53
|
+
if (
|
|
54
|
+
'HTTP download' in output
|
|
55
|
+
or 'downloading directly via HTTP' in output
|
|
56
|
+
or 'WebRTC not supported' in output
|
|
57
|
+
):
|
|
58
|
+
return TransferMode.HTTP_DIRECT
|
|
59
|
+
|
|
60
|
+
return TransferMode.UNKNOWN
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _detect_download_path(cls, output: str, cwd: Path) -> Path | None:
|
|
64
|
+
match = cls._DOWNLOADED_RE.search(output)
|
|
65
|
+
if match is None:
|
|
66
|
+
match = cls._DOWNLOADING_RE.search(output)
|
|
67
|
+
|
|
68
|
+
if match is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return (cwd / match.group(1).strip()).resolve()
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def parse_download(
|
|
75
|
+
cls,
|
|
76
|
+
process: APEProcessResult,
|
|
77
|
+
requested_output_path: str | Path | None,
|
|
78
|
+
cwd: Path,
|
|
79
|
+
) -> DownloadResult:
|
|
80
|
+
output = cls._combined_output(process)
|
|
81
|
+
if requested_output_path is not None:
|
|
82
|
+
output_path = Path(requested_output_path).expanduser().resolve()
|
|
83
|
+
else:
|
|
84
|
+
output_path = cls._detect_download_path(output, cwd)
|
|
85
|
+
|
|
86
|
+
return DownloadResult(
|
|
87
|
+
process=process,
|
|
88
|
+
output_path=output_path,
|
|
89
|
+
transfer_mode=cls._detect_transfer_mode(output),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def parse_keygen(cls, process: APEProcessResult, cwd: Path) -> KeygenResult:
|
|
94
|
+
output = cls._combined_output(process)
|
|
95
|
+
private_match = cls._PRIVATE_KEY_RE.search(output)
|
|
96
|
+
public_match = cls._PUBLIC_KEY_RE.search(output)
|
|
97
|
+
if private_match is None or public_match is None:
|
|
98
|
+
raise FFLOutputError('FFL keygen output did not contain both key paths')
|
|
99
|
+
|
|
100
|
+
private_key_path = (cwd / private_match.group(1).strip()).resolve()
|
|
101
|
+
public_key_path = (cwd / public_match.group(1).strip()).resolve()
|
|
102
|
+
|
|
103
|
+
if not private_key_path.is_file() or not public_key_path.is_file():
|
|
104
|
+
raise FFLOutputError('FFL keygen reported key files that do not exist')
|
|
105
|
+
|
|
106
|
+
return KeygenResult(
|
|
107
|
+
process=process,
|
|
108
|
+
private_key_path=private_key_path,
|
|
109
|
+
public_key_path=public_key_path,
|
|
110
|
+
)
|
ffl/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Generated by APEBind. Do not edit directly.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ffl-python
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Python binding for FastFileLink, backed by the portable ffl.com APE
|
|
5
|
+
Author: ffl-python contributors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/nuwainfo/ffl-python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
16
|
+
Requires-Dist: PyYAML>=6.0; extra == "dev"
|
|
17
|
+
Requires-Dist: setuptools>=68; extra == "dev"
|
|
18
|
+
Requires-Dist: wheel>=0.42; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# ffl-python
|
|
22
|
+
|
|
23
|
+
Python binding for FastFileLink. The package bundles the portable `ffl.com` APE and
|
|
24
|
+
runs it behind a Python API; callers do not need to locate or install a separate FFL
|
|
25
|
+
binary.
|
|
26
|
+
|
|
27
|
+
The low-level command grammar is generated by APEBind from `binding/ffl.apebind.yaml`.
|
|
28
|
+
The public API in `src/ffl/client.py` is intentionally handwritten so FFL-specific
|
|
29
|
+
library semantics stay explicit and reviewable.
|
|
30
|
+
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m venv .venv
|
|
35
|
+
.venv/Scripts/python -m pip install -e ".[dev]" # Windows
|
|
36
|
+
# or: .venv/bin/python -m pip install -e ".[dev]"
|
|
37
|
+
|
|
38
|
+
.\scripts\test.ps1 # Windows
|
|
39
|
+
# or: ./scripts/test.sh # Linux/macOS
|
|
40
|
+
|
|
41
|
+
.\scripts\build.ps1 # Windows
|
|
42
|
+
# or: ./scripts/build.sh # Linux/macOS
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The test directly imports `ffl`, shares a binary file, and downloads it through the
|
|
46
|
+
bundled `ffl.com` APE. It requires working network access to FastFileLink. A wheel is a
|
|
47
|
+
build artifact, not the source of truth.
|
|
48
|
+
|
|
49
|
+
The integration suite covers ordinary, E2EE, relay, pickup-code, and public-key
|
|
50
|
+
transfers; text, bytes, folders, multiple files, QR images, hooks, explicit ports, and
|
|
51
|
+
session shutdown, and Basic Auth download.
|
|
52
|
+
|
|
53
|
+
## Share
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import ffl
|
|
57
|
+
|
|
58
|
+
with ffl.share("release.zip", max_downloads=1, timeout_seconds=1800) as session:
|
|
59
|
+
print(session.link)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`share()` always requests a foreground FFL process and disables clipboard side effects,
|
|
63
|
+
which gives library callers a deterministic `ShareSession` they can stop or keep alive.
|
|
64
|
+
FFL's runtime-owned `--json` output is used internally to wait until `session.link` is
|
|
65
|
+
ready.
|
|
66
|
+
|
|
67
|
+
Multiple files are accepted directly:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
session = ffl.share(["one.txt", "two.txt"], name="files.zip")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Text and bytes helpers own their temporary file until the share session is closed:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
with ffl.share_text("hello", name="hello.txt") as session:
|
|
77
|
+
print(session.link)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Optional-value FFL flags use natural Python values. For example, `receipt=True` emits
|
|
81
|
+
`--receipt` without a value, while `receipt="me@example.com"` emits the flag with the
|
|
82
|
+
address.
|
|
83
|
+
|
|
84
|
+
### Stream a source without a temporary file
|
|
85
|
+
|
|
86
|
+
`share_stream()` passes a binary file-like object directly to FFL stdin. It is useful
|
|
87
|
+
for database dumps, generated artifacts, and other data that should not first be
|
|
88
|
+
materialized as a separate temporary file:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
with open("backup.tar", "rb") as source:
|
|
92
|
+
with ffl.share_stream(source, name="backup.tar") as session:
|
|
93
|
+
print(session.link)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Download
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
result = ffl.download("https://example.fastfilelink/...", output_path="download.bin")
|
|
100
|
+
print(result.output_path)
|
|
101
|
+
print(result.transfer_mode)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`download()` waits for the foreground FFL process to finish and returns a
|
|
105
|
+
`DownloadResult`. For cancellation, progress, or caller-controlled timeouts, use
|
|
106
|
+
`start_download()` and manage its `DownloadSession` explicitly.
|
|
107
|
+
|
|
108
|
+
### Stream a download
|
|
109
|
+
|
|
110
|
+
`download_stream()` exposes FFL stdout as binary chunks without buffering the whole
|
|
111
|
+
file in memory:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
with ffl.download_stream("https://example.fastfilelink/...") as transfer:
|
|
115
|
+
with open("output.bin", "wb") as target:
|
|
116
|
+
for chunk in transfer.iter_bytes():
|
|
117
|
+
target.write(chunk)
|
|
118
|
+
|
|
119
|
+
result = transfer.wait()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Consume `iter_bytes()` through EOF before calling `wait()`. Calling `wait()` with
|
|
123
|
+
unconsumed streamed stdout raises `RuntimeError`; this avoids silently discarding
|
|
124
|
+
binary data or deadlocking when the child process fills its stdout pipe.
|
|
125
|
+
|
|
126
|
+
## Authentication secrets
|
|
127
|
+
|
|
128
|
+
For shares protected with HTTP Basic Auth, FFL supports `FFL_AUTH_PASSWORD`. Set it in
|
|
129
|
+
the application environment and pass only `auth_user` to keep the password out of the
|
|
130
|
+
FFL command line:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
export FFL_AUTH_PASSWORD='use-your-secret-manager'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```powershell
|
|
137
|
+
$env:FFL_AUTH_PASSWORD = 'use-your-secret-manager'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
with ffl.share("release.zip", auth_user="deploy") as session:
|
|
142
|
+
print(session.link)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Do not also pass `auth_password=` when using this pattern: FFL gives the explicit CLI
|
|
146
|
+
option precedence over `FFL_AUTH_PASSWORD`. The environment variable applies to the
|
|
147
|
+
sharing side; pass download credentials explicitly when downloading a protected link.
|
|
148
|
+
|
|
149
|
+
## Key generation
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
result = ffl.keygen("alice")
|
|
153
|
+
print(result.public_key_path)
|
|
154
|
+
print(result.private_key_path)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`keygen()` has a 60-second process timeout and verifies that the key paths reported by
|
|
158
|
+
FFL exist.
|
|
159
|
+
|
|
160
|
+
## Version and raw access
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
print(ffl.version())
|
|
164
|
+
result = ffl.raw(["download", "--help"])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`raw()` is the escape hatch for new FFL options or commands that the semantic API has
|
|
168
|
+
not adopted yet.
|
|
169
|
+
|
|
170
|
+
## WSL2
|
|
171
|
+
|
|
172
|
+
If an operation fails with `TLSError([0x6300])`, WSL may be routing the bundled
|
|
173
|
+
`.com` APE through Windows interop. Run the following in WSL, then restart the WSL
|
|
174
|
+
session:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
sudo sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/WSLInterop'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Updating the FFL binding
|
|
181
|
+
|
|
182
|
+
Install APEBind from its source project. When adopting a new `ffl.com`, first inspect the
|
|
183
|
+
CLI into a raw discovery file:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
./scripts/inspect.sh /path/to/ffl.com
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
This writes `binding/ffl.discovered.apebind.yaml` and automatically applies the hidden
|
|
190
|
+
command seeds in `binding/ffl.commands.yaml`. Review the discovered-schema diff, then
|
|
191
|
+
manually merge CLI changes into the canonical semantic contract
|
|
192
|
+
`binding/ffl.apebind.yaml`. Automatic inspection never overwrites the semantic contract.
|
|
193
|
+
|
|
194
|
+
After reviewing the semantic schema, regenerate the low-level binding:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
./scripts/regenerate.sh /path/to/ffl.com
|
|
198
|
+
# Windows: .\scripts\regenerate.ps1 D:\ffl.com
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The script invokes APEBind into an isolated temporary project and replaces only these
|
|
202
|
+
machine-owned files:
|
|
203
|
+
|
|
204
|
+
- `src/ffl/_generated.py`
|
|
205
|
+
- `src/ffl/_runtime.py`
|
|
206
|
+
- `src/ffl/bin/ffl.com`
|
|
207
|
+
- `src/ffl/py.typed`
|
|
208
|
+
|
|
209
|
+
It deliberately does not overwrite `client.py`, `models.py`, parsing logic, tests, or the
|
|
210
|
+
semantic schema.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ffl/__init__.py,sha256=GQ8-MHqcrG1mT0Jkqdo1ifZdMp9j7YVOvsk8QP6o7so,1887
|
|
2
|
+
ffl/_generated.py,sha256=y_o02NvGXOciWYmBzCjsh3Ro-ObtJQunWYAomPmh6ec,44072
|
|
3
|
+
ffl/_runtime.py,sha256=hb4lQYWY51iupkBIXZk5Ok1aV2PJv6KoEdJKmcjmhrU,34800
|
|
4
|
+
ffl/client.py,sha256=ZmpMNr0zDu9djxshdtkrkcwGTaNVZbdgxPrfw_1WuI8,10649
|
|
5
|
+
ffl/errors.py,sha256=RarGTbHUzMYJ8ifYcwbQh3guNHHH8sPKPuxXgceLvtY,965
|
|
6
|
+
ffl/events.py,sha256=V6NYG0Tfw8XR4YLF2tUqRWMhE6fRE-tnvENAoffeSV4,9263
|
|
7
|
+
ffl/models.py,sha256=yqV2_HwgYJXS2FIzVxLycwU8_j0ORKzFXNlceonh52A,7975
|
|
8
|
+
ffl/parsing.py,sha256=DPtgoMbbbKyeE0aqTY5iKpfRnUJBhpTnqmH3NVG6G8U,3930
|
|
9
|
+
ffl/py.typed,sha256=qzxecjP9nBZND4fnAxWaIqIc3-9Z7bDos33dUet8d9g,46
|
|
10
|
+
ffl/bin/ffl.com,sha256=DGFM-Lh6HnP96GsEkRokbdPKlCCBnP6KBejtwDFGc3s,51721850
|
|
11
|
+
ffl_python-0.1.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
12
|
+
ffl_python-0.1.4.dist-info/METADATA,sha256=hean35OifKffdfYtKxv_HC9JdfvQ9-WLiOKYs98-t5k,6583
|
|
13
|
+
ffl_python-0.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
ffl_python-0.1.4.dist-info/top_level.txt,sha256=I3tRB8VSu6Bs5i_luGnIYh-dBWVtyqtLjpAfJqGtFMA,4
|
|
15
|
+
ffl_python-0.1.4.dist-info/RECORD,,
|