ffl-python 0.1.4__tar.gz → 0.1.5__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 (36) hide show
  1. {ffl_python-0.1.4 → ffl_python-0.1.5}/PKG-INFO +71 -63
  2. ffl_python-0.1.5/README.md +198 -0
  3. {ffl_python-0.1.4 → ffl_python-0.1.5}/pyproject.toml +1 -1
  4. ffl_python-0.1.5/src/ffl/bin/ffl.com +4 -0
  5. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/events.py +16 -7
  6. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/models.py +2 -0
  7. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/parsing.py +5 -5
  8. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl_python.egg-info/PKG-INFO +71 -63
  9. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_events.py +53 -6
  10. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_hook.py +11 -1
  11. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_session.py +26 -0
  12. ffl_python-0.1.4/README.md +0 -190
  13. ffl_python-0.1.4/src/ffl/bin/ffl.com +0 -4
  14. {ffl_python-0.1.4 → ffl_python-0.1.5}/LICENSE +0 -0
  15. {ffl_python-0.1.4 → ffl_python-0.1.5}/setup.cfg +0 -0
  16. {ffl_python-0.1.4 → ffl_python-0.1.5}/setup.py +0 -0
  17. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/__init__.py +0 -0
  18. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/_generated.py +0 -0
  19. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/_runtime.py +0 -0
  20. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/client.py +0 -0
  21. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/errors.py +0 -0
  22. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl/py.typed +0 -0
  23. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl_python.egg-info/SOURCES.txt +0 -0
  24. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl_python.egg-info/dependency_links.txt +0 -0
  25. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl_python.egg-info/requires.txt +0 -0
  26. {ffl_python-0.1.4 → ffl_python-0.1.5}/src/ffl_python.egg-info/top_level.txt +0 -0
  27. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_auth.py +0 -0
  28. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_e2ee.py +0 -0
  29. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_folder.py +0 -0
  30. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_packaging.py +0 -0
  31. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_port.py +0 -0
  32. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_qr.py +0 -0
  33. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_recipient_auth.py +0 -0
  34. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_relay.py +0 -0
  35. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_stream.py +0 -0
  36. {ffl_python-0.1.4 → ffl_python-0.1.5}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffl-python
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Python binding for FastFileLink, backed by the portable ffl.com APE
5
5
  Author: ffl-python contributors
6
6
  License-Expression: Apache-2.0
@@ -20,13 +20,24 @@ Dynamic: license-file
20
20
 
21
21
  # ffl-python
22
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.
23
+ `ffl-python` is the Python binding for the [FastFileLink](https://github.com/nuwainfo/ffl)
24
+ CLI (FFL), which turns a file, folder, or stream into a browser-ready HTTPS link so the
25
+ recipient can download it without installing anything. It prefers a direct QUIC/WebRTC P2P
26
+ connection and falls back to a relayed/tunneled HTTPS link, with optional end-to-end
27
+ encryption. See the FFL repository for the full protocol and CLI details.
26
28
 
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.
29
+ The package bundles the portable `ffl.com` APE and runs it behind a Python API,
30
+ powered by [APEBind](https://github.com/nuwainfo/apebind),
31
+ so callers do not need to locate or install a separate FFL
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install ffl-python
37
+ ```
38
+
39
+ This installs the `ffl` package with the bundled `ffl.com` APE included -- no separate
40
+ FFL install or PATH setup required.
30
41
 
31
42
  ## Development
32
43
 
@@ -42,14 +53,6 @@ python -m venv .venv
42
53
  # or: ./scripts/build.sh # Linux/macOS
43
54
  ```
44
55
 
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
56
  ## Share
54
57
 
55
58
  ```python
@@ -77,10 +80,6 @@ with ffl.share_text("hello", name="hello.txt") as session:
77
80
  print(session.link)
78
81
  ```
79
82
 
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
83
  ### Stream a source without a temporary file
85
84
 
86
85
  `share_stream()` passes a binary file-like object directly to FFL stdin. It is useful
@@ -93,6 +92,25 @@ with open("backup.tar", "rb") as source:
93
92
  print(session.link)
94
93
  ```
95
94
 
95
+ ### Events
96
+
97
+ `session.on(name, listener)` follows FFL's integrated events:
98
+
99
+ | Semantic name | FFL event |
100
+ | --- | --- |
101
+ | `ready` | `/share/available` |
102
+ | `started` | `/transfer/create` |
103
+ | `progress` | `/transfer/progress` |
104
+ | `completed` | `/transfer/complete` |
105
+ | `failed` | `/transfer/fail` |
106
+
107
+ HTTP, WebRTC, and direct P2P also emit their own events, such as
108
+ `/download/complete`. Those stay on `session.on_raw()`. `completed` follows
109
+ `/transfer/complete`, so one transfer notifies that listener once. Each accepted
110
+ hook POST is answered with HTTP 200 and `{}`.
111
+
112
+ The bundled `ffl.com` emits these names.
113
+
96
114
  ## Download
97
115
 
98
116
  ```python
@@ -154,9 +172,6 @@ print(result.public_key_path)
154
172
  print(result.private_key_path)
155
173
  ```
156
174
 
157
- `keygen()` has a 60-second process timeout and verifies that the key paths reported by
158
- FFL exist.
159
-
160
175
  ## Version and raw access
161
176
 
162
177
  ```python
@@ -167,44 +182,37 @@ result = ffl.raw(["download", "--help"])
167
182
  `raw()` is the escape hatch for new FFL options or commands that the semantic API has
168
183
  not adopted yet.
169
184
 
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.
185
+ ## Compared to magic-wormhole
186
+
187
+ [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole) is the other Python
188
+ tool commonly reached for to move a file between two machines. Both are ad hoc,
189
+ non-account-based transfers you can drive from Python, but they differ in shape:
190
+
191
+ - **Binding vs. native library.** `ffl-python` is a subprocess wrapper around the
192
+ separate `ffl.com` CLI binary -- WebRTC/QUIC, NAT traversal, and relay/tunnel fallback
193
+ all happen in that external process. `wormhole` is a native, in-process Python package
194
+ (Twisted-based); no external binary is involved. In practice, though, wormhole's own
195
+ *file*-transfer path is also driven through its CLI machinery rather than a stable
196
+ public library call -- `wormhole.create()` covers generic message exchange, not file
197
+ transfer directly.
198
+ - **Recipient experience.** An FFL share is an HTTPS link: the recipient opens it in a
199
+ browser and downloads, no install required. A wormhole transfer is a short code
200
+ (e.g. `7-crossbow-clockwork`) exchanged out-of-band; the recipient needs the
201
+ `wormhole` CLI installed to redeem it.
202
+ - **Transport.** FFL tries a direct WebRTC/QUIC P2P connection first, falls back to a
203
+ plain P2P TCP connection, and falls back again to a relayed/tunneled HTTPS link if no
204
+ P2P path is reachable at all. wormhole's own transit protocol does direct TCP with a
205
+ relay fallback, with the connection authenticated by a SPAKE2 PAKE key derived from the
206
+ code.
207
+ - **Security default.** wormhole is end-to-end encrypted on every transfer by
208
+ construction of the code exchange. FFL's end-to-end encryption is opt-in (`--e2ee`);
209
+ without it, data in transit is only as protected as the HTTPS connection to FFL's
210
+ relay/tunnel infrastructure.
211
+ - **Feature surface.** FFL adds application-layer conveniences wormhole doesn't have: a
212
+ secondary pickup-code/public-key recipient check layered on top of the link,
213
+ receipt-confirmation emails, a pluggable choice of tunnel backend when P2P isn't
214
+ reachable (built-in `default`, plus Cloudflare, ngrok, Localtunnel, Loophole, Dev
215
+ Tunnel, Bore, or a self-hosted sish tunnel via `--preferred-tunnel`, with custom
216
+ tunnels configurable in `~/.fastfilelink/tunnels.json`), and general SOCKS5/HTTP
217
+ proxy configuration (wormhole only knows how to route through Tor, via `--tor`).
218
+
@@ -0,0 +1,198 @@
1
+ # ffl-python
2
+
3
+ `ffl-python` is the Python binding for the [FastFileLink](https://github.com/nuwainfo/ffl)
4
+ CLI (FFL), which turns a file, folder, or stream into a browser-ready HTTPS link so the
5
+ recipient can download it without installing anything. It prefers a direct QUIC/WebRTC P2P
6
+ connection and falls back to a relayed/tunneled HTTPS link, with optional end-to-end
7
+ encryption. See the FFL repository for the full protocol and CLI details.
8
+
9
+ The package bundles the portable `ffl.com` APE and runs it behind a Python API,
10
+ powered by [APEBind](https://github.com/nuwainfo/apebind),
11
+ so callers do not need to locate or install a separate FFL
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install ffl-python
17
+ ```
18
+
19
+ This installs the `ffl` package with the bundled `ffl.com` APE included -- no separate
20
+ FFL install or PATH setup required.
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ python -m venv .venv
26
+ .venv/Scripts/python -m pip install -e ".[dev]" # Windows
27
+ # or: .venv/bin/python -m pip install -e ".[dev]"
28
+
29
+ .\scripts\test.ps1 # Windows
30
+ # or: ./scripts/test.sh # Linux/macOS
31
+
32
+ .\scripts\build.ps1 # Windows
33
+ # or: ./scripts/build.sh # Linux/macOS
34
+ ```
35
+
36
+ ## Share
37
+
38
+ ```python
39
+ import ffl
40
+
41
+ with ffl.share("release.zip", max_downloads=1, timeout_seconds=1800) as session:
42
+ print(session.link)
43
+ ```
44
+
45
+ `share()` always requests a foreground FFL process and disables clipboard side effects,
46
+ which gives library callers a deterministic `ShareSession` they can stop or keep alive.
47
+ FFL's runtime-owned `--json` output is used internally to wait until `session.link` is
48
+ ready.
49
+
50
+ Multiple files are accepted directly:
51
+
52
+ ```python
53
+ session = ffl.share(["one.txt", "two.txt"], name="files.zip")
54
+ ```
55
+
56
+ Text and bytes helpers own their temporary file until the share session is closed:
57
+
58
+ ```python
59
+ with ffl.share_text("hello", name="hello.txt") as session:
60
+ print(session.link)
61
+ ```
62
+
63
+ ### Stream a source without a temporary file
64
+
65
+ `share_stream()` passes a binary file-like object directly to FFL stdin. It is useful
66
+ for database dumps, generated artifacts, and other data that should not first be
67
+ materialized as a separate temporary file:
68
+
69
+ ```python
70
+ with open("backup.tar", "rb") as source:
71
+ with ffl.share_stream(source, name="backup.tar") as session:
72
+ print(session.link)
73
+ ```
74
+
75
+ ### Events
76
+
77
+ `session.on(name, listener)` follows FFL's integrated events:
78
+
79
+ | Semantic name | FFL event |
80
+ | --- | --- |
81
+ | `ready` | `/share/available` |
82
+ | `started` | `/transfer/create` |
83
+ | `progress` | `/transfer/progress` |
84
+ | `completed` | `/transfer/complete` |
85
+ | `failed` | `/transfer/fail` |
86
+
87
+ HTTP, WebRTC, and direct P2P also emit their own events, such as
88
+ `/download/complete`. Those stay on `session.on_raw()`. `completed` follows
89
+ `/transfer/complete`, so one transfer notifies that listener once. Each accepted
90
+ hook POST is answered with HTTP 200 and `{}`.
91
+
92
+ The bundled `ffl.com` emits these names.
93
+
94
+ ## Download
95
+
96
+ ```python
97
+ result = ffl.download("https://example.fastfilelink/...", output_path="download.bin")
98
+ print(result.output_path)
99
+ print(result.transfer_mode)
100
+ ```
101
+
102
+ `download()` waits for the foreground FFL process to finish and returns a
103
+ `DownloadResult`. For cancellation, progress, or caller-controlled timeouts, use
104
+ `start_download()` and manage its `DownloadSession` explicitly.
105
+
106
+ ### Stream a download
107
+
108
+ `download_stream()` exposes FFL stdout as binary chunks without buffering the whole
109
+ file in memory:
110
+
111
+ ```python
112
+ with ffl.download_stream("https://example.fastfilelink/...") as transfer:
113
+ with open("output.bin", "wb") as target:
114
+ for chunk in transfer.iter_bytes():
115
+ target.write(chunk)
116
+
117
+ result = transfer.wait()
118
+ ```
119
+
120
+ Consume `iter_bytes()` through EOF before calling `wait()`. Calling `wait()` with
121
+ unconsumed streamed stdout raises `RuntimeError`; this avoids silently discarding
122
+ binary data or deadlocking when the child process fills its stdout pipe.
123
+
124
+ ## Authentication secrets
125
+
126
+ For shares protected with HTTP Basic Auth, FFL supports `FFL_AUTH_PASSWORD`. Set it in
127
+ the application environment and pass only `auth_user` to keep the password out of the
128
+ FFL command line:
129
+
130
+ ```bash
131
+ export FFL_AUTH_PASSWORD='use-your-secret-manager'
132
+ ```
133
+
134
+ ```powershell
135
+ $env:FFL_AUTH_PASSWORD = 'use-your-secret-manager'
136
+ ```
137
+
138
+ ```python
139
+ with ffl.share("release.zip", auth_user="deploy") as session:
140
+ print(session.link)
141
+ ```
142
+
143
+ Do not also pass `auth_password=` when using this pattern: FFL gives the explicit CLI
144
+ option precedence over `FFL_AUTH_PASSWORD`. The environment variable applies to the
145
+ sharing side; pass download credentials explicitly when downloading a protected link.
146
+
147
+ ## Key generation
148
+
149
+ ```python
150
+ result = ffl.keygen("alice")
151
+ print(result.public_key_path)
152
+ print(result.private_key_path)
153
+ ```
154
+
155
+ ## Version and raw access
156
+
157
+ ```python
158
+ print(ffl.version())
159
+ result = ffl.raw(["download", "--help"])
160
+ ```
161
+
162
+ `raw()` is the escape hatch for new FFL options or commands that the semantic API has
163
+ not adopted yet.
164
+
165
+ ## Compared to magic-wormhole
166
+
167
+ [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole) is the other Python
168
+ tool commonly reached for to move a file between two machines. Both are ad hoc,
169
+ non-account-based transfers you can drive from Python, but they differ in shape:
170
+
171
+ - **Binding vs. native library.** `ffl-python` is a subprocess wrapper around the
172
+ separate `ffl.com` CLI binary -- WebRTC/QUIC, NAT traversal, and relay/tunnel fallback
173
+ all happen in that external process. `wormhole` is a native, in-process Python package
174
+ (Twisted-based); no external binary is involved. In practice, though, wormhole's own
175
+ *file*-transfer path is also driven through its CLI machinery rather than a stable
176
+ public library call -- `wormhole.create()` covers generic message exchange, not file
177
+ transfer directly.
178
+ - **Recipient experience.** An FFL share is an HTTPS link: the recipient opens it in a
179
+ browser and downloads, no install required. A wormhole transfer is a short code
180
+ (e.g. `7-crossbow-clockwork`) exchanged out-of-band; the recipient needs the
181
+ `wormhole` CLI installed to redeem it.
182
+ - **Transport.** FFL tries a direct WebRTC/QUIC P2P connection first, falls back to a
183
+ plain P2P TCP connection, and falls back again to a relayed/tunneled HTTPS link if no
184
+ P2P path is reachable at all. wormhole's own transit protocol does direct TCP with a
185
+ relay fallback, with the connection authenticated by a SPAKE2 PAKE key derived from the
186
+ code.
187
+ - **Security default.** wormhole is end-to-end encrypted on every transfer by
188
+ construction of the code exchange. FFL's end-to-end encryption is opt-in (`--e2ee`);
189
+ without it, data in transit is only as protected as the HTTPS connection to FFL's
190
+ relay/tunnel infrastructure.
191
+ - **Feature surface.** FFL adds application-layer conveniences wormhole doesn't have: a
192
+ secondary pickup-code/public-key recipient check layered on top of the link,
193
+ receipt-confirmation emails, a pluggable choice of tunnel backend when P2P isn't
194
+ reachable (built-in `default`, plus Cloudflare, ngrok, Localtunnel, Loophole, Dev
195
+ Tunnel, Bore, or a self-hosted sish tunnel via `--preferred-tunnel`, with custom
196
+ tunnels configurable in `~/.fastfilelink/tunnels.json`), and general SOCKS5/HTTP
197
+ proxy configuration (wormhole only knows how to route through Tor, via `--tor`).
198
+
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ffl-python"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Python binding for FastFileLink, backed by the portable ffl.com APE"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: ffl_python-0.1.5/src/ffl/bin/ffl.com
3
+ size: 48780381 bytes
4
+ sha256: 15cab56cc9f9a86fb5ae7f521a85667b91cb8bbfc9704c9105d1a79634e24396
@@ -31,11 +31,17 @@ from urllib.error import URLError
31
31
  from urllib.request import Request, urlopen
32
32
 
33
33
 
34
+ # FFL posts one integrated /transfer/* event for every HTTP, WebRTC, and
35
+ # direct P2P transfer. Semantic listeners bind to those names, plus
36
+ # /share/available. Transport-specific events stay on the raw channel, so one
37
+ # transfer notifies each semantic listener once.
38
+ _HOOK_OK_BODY = b'{}'
34
39
  _SEMANTIC_EVENT_NAMES = {
35
- '/hook/server/endpoints/register': 'ready',
36
- '/hook/transfer/progress': 'progress',
37
- '/hook/transfer/transport': 'transport',
38
- '/hook/transfer/complete': 'completed',
40
+ '/share/available': 'ready',
41
+ '/transfer/create': 'started',
42
+ '/transfer/progress': 'progress',
43
+ '/transfer/complete': 'completed',
44
+ '/transfer/fail': 'failed',
39
45
  }
40
46
 
41
47
 
@@ -176,12 +182,15 @@ class FFLHookEventChannel:
176
182
  try:
177
183
  channel._publish(body)
178
184
  published = True
179
- self.send_response(204)
185
+ self.send_response(200)
186
+ self.send_header('Content-Type', 'application/json')
187
+ self.send_header('Content-Length', str(len(_HOOK_OK_BODY)))
188
+ self.end_headers()
189
+ self.wfile.write(_HOOK_OK_BODY)
180
190
  except Exception as error:
181
191
  channel._record_error(error)
182
192
  self.send_response(500)
183
-
184
- self.end_headers()
193
+ self.end_headers()
185
194
 
186
195
  if published and channel._forward_url is not None:
187
196
  channel._forward_async(body)
@@ -34,6 +34,8 @@ class TransferMode(IntEnum):
34
34
  WEBRTC_P2P = 2
35
35
  HTTP_FALLBACK = 3
36
36
  HTTP_DIRECT = 4
37
+ P2P_TCP = 5
38
+ P2P_QUIC = 6
37
39
 
38
40
 
39
41
  class ShareSession:
@@ -42,12 +42,12 @@ class FFLResultParser:
42
42
 
43
43
  @classmethod
44
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
- ):
45
+ if 'P2P direct' in output or 'WebRTC P2P' in output:
50
46
  return TransferMode.WEBRTC_P2P
47
+ if 'P2P UDP/QUIC' in output:
48
+ return TransferMode.P2P_QUIC
49
+ if 'P2P TCP' in output:
50
+ return TransferMode.P2P_TCP
51
51
  if 'HTTP fallback' in output:
52
52
  return TransferMode.HTTP_FALLBACK
53
53
  if (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffl-python
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Python binding for FastFileLink, backed by the portable ffl.com APE
5
5
  Author: ffl-python contributors
6
6
  License-Expression: Apache-2.0
@@ -20,13 +20,24 @@ Dynamic: license-file
20
20
 
21
21
  # ffl-python
22
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.
23
+ `ffl-python` is the Python binding for the [FastFileLink](https://github.com/nuwainfo/ffl)
24
+ CLI (FFL), which turns a file, folder, or stream into a browser-ready HTTPS link so the
25
+ recipient can download it without installing anything. It prefers a direct QUIC/WebRTC P2P
26
+ connection and falls back to a relayed/tunneled HTTPS link, with optional end-to-end
27
+ encryption. See the FFL repository for the full protocol and CLI details.
26
28
 
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.
29
+ The package bundles the portable `ffl.com` APE and runs it behind a Python API,
30
+ powered by [APEBind](https://github.com/nuwainfo/apebind),
31
+ so callers do not need to locate or install a separate FFL
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install ffl-python
37
+ ```
38
+
39
+ This installs the `ffl` package with the bundled `ffl.com` APE included -- no separate
40
+ FFL install or PATH setup required.
30
41
 
31
42
  ## Development
32
43
 
@@ -42,14 +53,6 @@ python -m venv .venv
42
53
  # or: ./scripts/build.sh # Linux/macOS
43
54
  ```
44
55
 
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
56
  ## Share
54
57
 
55
58
  ```python
@@ -77,10 +80,6 @@ with ffl.share_text("hello", name="hello.txt") as session:
77
80
  print(session.link)
78
81
  ```
79
82
 
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
83
  ### Stream a source without a temporary file
85
84
 
86
85
  `share_stream()` passes a binary file-like object directly to FFL stdin. It is useful
@@ -93,6 +92,25 @@ with open("backup.tar", "rb") as source:
93
92
  print(session.link)
94
93
  ```
95
94
 
95
+ ### Events
96
+
97
+ `session.on(name, listener)` follows FFL's integrated events:
98
+
99
+ | Semantic name | FFL event |
100
+ | --- | --- |
101
+ | `ready` | `/share/available` |
102
+ | `started` | `/transfer/create` |
103
+ | `progress` | `/transfer/progress` |
104
+ | `completed` | `/transfer/complete` |
105
+ | `failed` | `/transfer/fail` |
106
+
107
+ HTTP, WebRTC, and direct P2P also emit their own events, such as
108
+ `/download/complete`. Those stay on `session.on_raw()`. `completed` follows
109
+ `/transfer/complete`, so one transfer notifies that listener once. Each accepted
110
+ hook POST is answered with HTTP 200 and `{}`.
111
+
112
+ The bundled `ffl.com` emits these names.
113
+
96
114
  ## Download
97
115
 
98
116
  ```python
@@ -154,9 +172,6 @@ print(result.public_key_path)
154
172
  print(result.private_key_path)
155
173
  ```
156
174
 
157
- `keygen()` has a 60-second process timeout and verifies that the key paths reported by
158
- FFL exist.
159
-
160
175
  ## Version and raw access
161
176
 
162
177
  ```python
@@ -167,44 +182,37 @@ result = ffl.raw(["download", "--help"])
167
182
  `raw()` is the escape hatch for new FFL options or commands that the semantic API has
168
183
  not adopted yet.
169
184
 
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.
185
+ ## Compared to magic-wormhole
186
+
187
+ [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole) is the other Python
188
+ tool commonly reached for to move a file between two machines. Both are ad hoc,
189
+ non-account-based transfers you can drive from Python, but they differ in shape:
190
+
191
+ - **Binding vs. native library.** `ffl-python` is a subprocess wrapper around the
192
+ separate `ffl.com` CLI binary -- WebRTC/QUIC, NAT traversal, and relay/tunnel fallback
193
+ all happen in that external process. `wormhole` is a native, in-process Python package
194
+ (Twisted-based); no external binary is involved. In practice, though, wormhole's own
195
+ *file*-transfer path is also driven through its CLI machinery rather than a stable
196
+ public library call -- `wormhole.create()` covers generic message exchange, not file
197
+ transfer directly.
198
+ - **Recipient experience.** An FFL share is an HTTPS link: the recipient opens it in a
199
+ browser and downloads, no install required. A wormhole transfer is a short code
200
+ (e.g. `7-crossbow-clockwork`) exchanged out-of-band; the recipient needs the
201
+ `wormhole` CLI installed to redeem it.
202
+ - **Transport.** FFL tries a direct WebRTC/QUIC P2P connection first, falls back to a
203
+ plain P2P TCP connection, and falls back again to a relayed/tunneled HTTPS link if no
204
+ P2P path is reachable at all. wormhole's own transit protocol does direct TCP with a
205
+ relay fallback, with the connection authenticated by a SPAKE2 PAKE key derived from the
206
+ code.
207
+ - **Security default.** wormhole is end-to-end encrypted on every transfer by
208
+ construction of the code exchange. FFL's end-to-end encryption is opt-in (`--e2ee`);
209
+ without it, data in transit is only as protected as the HTTPS connection to FFL's
210
+ relay/tunnel infrastructure.
211
+ - **Feature surface.** FFL adds application-layer conveniences wormhole doesn't have: a
212
+ secondary pickup-code/public-key recipient check layered on top of the link,
213
+ receipt-confirmation emails, a pluggable choice of tunnel backend when P2P isn't
214
+ reachable (built-in `default`, plus Cloudflare, ngrok, Localtunnel, Loophole, Dev
215
+ Tunnel, Bore, or a self-hosted sish tunnel via `--preferred-tunnel`, with custom
216
+ tunnels configurable in `~/.fastfilelink/tunnels.json`), and general SOCKS5/HTTP
217
+ proxy configuration (wormhole only knows how to route through Tor, via `--tor`).
218
+
@@ -11,7 +11,7 @@ import pytest
11
11
  from ffl.events import FFLHookEventChannel
12
12
 
13
13
 
14
- def _event_body(index: int, name: str = '/hook/transfer/progress') -> bytes:
14
+ def _event_body(index: int, name: str = '/transfer/progress') -> bytes:
15
15
  return json.dumps({'event': name, 'data': {'index': index}}).encode('utf-8')
16
16
 
17
17
 
@@ -20,7 +20,7 @@ def test_hook_history_is_bounded_and_exposes_semantic_event_names():
20
20
  try:
21
21
  channel._publish(_event_body(1))
22
22
  channel._publish(_event_body(2))
23
- channel._publish(_event_body(3, '/hook/transfer/complete'))
23
+ channel._publish(_event_body(3, '/transfer/complete'))
24
24
 
25
25
  assert [event.data['index'] for event in channel.history] == [2, 3]
26
26
  assert channel.history[-1].semantic_name == 'completed'
@@ -36,6 +36,47 @@ def test_hook_history_is_bounded_and_exposes_semantic_event_names():
36
36
  channel.close()
37
37
 
38
38
 
39
+ def test_integrated_transfer_events_map_once():
40
+ channel = FFLHookEventChannel()
41
+ try:
42
+ names = (
43
+ '/download/complete',
44
+ '/webrtc/transfer/complete',
45
+ '/p2p/transfer/complete',
46
+ '/hook/transfer/complete',
47
+ '/transfer/complete',
48
+ '/share/available',
49
+ '/transfer/create',
50
+ '/transfer/progress',
51
+ '/download/progress',
52
+ '/transfer/fail',
53
+ )
54
+ for index, name in enumerate(names, start=1):
55
+ channel._publish(_event_body(index, name))
56
+
57
+ def named(semantic):
58
+ return [
59
+ event.name for event in channel.history if event.semantic_name == semantic
60
+ ]
61
+
62
+ assert named('completed') == ['/transfer/complete']
63
+ assert named('progress') == ['/transfer/progress']
64
+ assert named('ready') == ['/share/available']
65
+ assert named('started') == ['/transfer/create']
66
+ assert named('failed') == ['/transfer/fail']
67
+
68
+ replayed = []
69
+ replayed_event = threading.Event()
70
+ channel.on_semantic(
71
+ 'completed',
72
+ lambda event: (replayed.append(event.name), replayed_event.set()),
73
+ )
74
+ assert replayed_event.wait(timeout=1)
75
+ assert replayed == ['/transfer/complete']
76
+ finally:
77
+ channel.close()
78
+
79
+
39
80
  def test_live_event_iterator_preserves_events_after_history_eviction():
40
81
  channel = FFLHookEventChannel(event_history_limit=2)
41
82
  iterator = channel.events()
@@ -69,8 +110,12 @@ def test_hook_response_does_not_wait_for_forwarded_webhook():
69
110
  def do_POST(self) -> None:
70
111
  forwarded.set()
71
112
  release_forward.wait(timeout=5)
72
- self.send_response(204)
113
+ body = b'{}'
114
+ self.send_response(200)
115
+ self.send_header('Content-Type', 'application/json')
116
+ self.send_header('Content-Length', str(len(body)))
73
117
  self.end_headers()
118
+ self.wfile.write(body)
74
119
 
75
120
  def log_message(self, format_string: str, *args) -> None:
76
121
  del format_string, args
@@ -84,7 +129,8 @@ def test_hook_response_does_not_wait_for_forwarded_webhook():
84
129
  started = time.monotonic()
85
130
  request = Request(channel.url, data=_event_body(1), method='POST')
86
131
  with urlopen(request, timeout=1) as response:
87
- assert response.status == 204
132
+ assert response.status == 200
133
+ assert response.read() == b'{}'
88
134
 
89
135
  assert time.monotonic() - started < 0.5
90
136
  assert forwarded.wait(timeout=1)
@@ -114,7 +160,7 @@ def test_hook_response_does_not_wait_for_a_local_listener():
114
160
  release_listener = threading.Event()
115
161
  channel = FFLHookEventChannel()
116
162
  channel.on(
117
- '/hook/transfer/progress',
163
+ '/transfer/progress',
118
164
  lambda event: (listener_started.set(), release_listener.wait(timeout=5)),
119
165
  )
120
166
 
@@ -122,7 +168,8 @@ def test_hook_response_does_not_wait_for_a_local_listener():
122
168
  started = time.monotonic()
123
169
  request = Request(channel.url, data=_event_body(1), method='POST')
124
170
  with urlopen(request, timeout=1) as response:
125
- assert response.status == 204
171
+ assert response.status == 200
172
+ assert response.read() == b'{}'
126
173
 
127
174
  assert time.monotonic() - started < 0.5
128
175
  assert listener_started.wait(timeout=1)
@@ -15,8 +15,12 @@ class _HookHandler(BaseHTTPRequestHandler):
15
15
  content_length = int(self.headers.get('Content-Length', '0'))
16
16
  self.__class__.requests.append((self.path, self.rfile.read(content_length)))
17
17
  self.__class__.received_event.set()
18
- self.send_response(204)
18
+ body = b'{}'
19
+ self.send_response(200)
20
+ self.send_header('Content-Type', 'application/json')
21
+ self.send_header('Content-Length', str(len(body)))
19
22
  self.end_headers()
23
+ self.wfile.write(body)
20
24
 
21
25
  def log_message(self, format_string: str, *args) -> None:
22
26
  del format_string, args
@@ -50,6 +54,12 @@ def test_share_sends_events_to_hook_url(tmp_path: Path, monkeypatch):
50
54
  if event.name == '/hook/server/endpoints/register'
51
55
  ]
52
56
  assert endpoint_events
57
+ completed = [
58
+ event.name
59
+ for event in session.event_history
60
+ if event.semantic_name == 'completed'
61
+ ]
62
+ assert completed == ['/transfer/complete']
53
63
  replayed = []
54
64
  session.on_raw('/hook/server/endpoints/register', replayed.append)
55
65
  assert replayed == endpoint_events
@@ -286,4 +286,30 @@ def test_download_parser_recognizes_p2p_tcp_output(tmp_path: Path):
286
286
 
287
287
  result = FFLResultParser.parse_download(process, None, tmp_path)
288
288
 
289
+ assert result.transfer_mode is ffl.TransferMode.P2P_TCP
290
+
291
+
292
+ def test_download_parser_recognizes_p2p_quic_output(tmp_path: Path):
293
+ process = ffl.APEProcessResult(
294
+ ('download', 'https://example.test/file'),
295
+ 0,
296
+ 'Using P2P UDP/QUIC download\nDownloaded: file.bin',
297
+ '',
298
+ )
299
+
300
+ result = FFLResultParser.parse_download(process, None, tmp_path)
301
+
302
+ assert result.transfer_mode is ffl.TransferMode.P2P_QUIC
303
+
304
+
305
+ def test_download_parser_recognizes_webrtc_p2p_output(tmp_path: Path):
306
+ process = ffl.APEProcessResult(
307
+ ('download', 'https://example.test/file'),
308
+ 0,
309
+ 'Using WebRTC P2P download\nDownloaded: file.bin',
310
+ '',
311
+ )
312
+
313
+ result = FFLResultParser.parse_download(process, None, tmp_path)
314
+
289
315
  assert result.transfer_mode is ffl.TransferMode.WEBRTC_P2P
@@ -1,190 +0,0 @@
1
- # ffl-python
2
-
3
- Python binding for FastFileLink. The package bundles the portable `ffl.com` APE and
4
- runs it behind a Python API; callers do not need to locate or install a separate FFL
5
- binary.
6
-
7
- The low-level command grammar is generated by APEBind from `binding/ffl.apebind.yaml`.
8
- The public API in `src/ffl/client.py` is intentionally handwritten so FFL-specific
9
- library semantics stay explicit and reviewable.
10
-
11
- ## Development
12
-
13
- ```bash
14
- python -m venv .venv
15
- .venv/Scripts/python -m pip install -e ".[dev]" # Windows
16
- # or: .venv/bin/python -m pip install -e ".[dev]"
17
-
18
- .\scripts\test.ps1 # Windows
19
- # or: ./scripts/test.sh # Linux/macOS
20
-
21
- .\scripts\build.ps1 # Windows
22
- # or: ./scripts/build.sh # Linux/macOS
23
- ```
24
-
25
- The test directly imports `ffl`, shares a binary file, and downloads it through the
26
- bundled `ffl.com` APE. It requires working network access to FastFileLink. A wheel is a
27
- build artifact, not the source of truth.
28
-
29
- The integration suite covers ordinary, E2EE, relay, pickup-code, and public-key
30
- transfers; text, bytes, folders, multiple files, QR images, hooks, explicit ports, and
31
- session shutdown, and Basic Auth download.
32
-
33
- ## Share
34
-
35
- ```python
36
- import ffl
37
-
38
- with ffl.share("release.zip", max_downloads=1, timeout_seconds=1800) as session:
39
- print(session.link)
40
- ```
41
-
42
- `share()` always requests a foreground FFL process and disables clipboard side effects,
43
- which gives library callers a deterministic `ShareSession` they can stop or keep alive.
44
- FFL's runtime-owned `--json` output is used internally to wait until `session.link` is
45
- ready.
46
-
47
- Multiple files are accepted directly:
48
-
49
- ```python
50
- session = ffl.share(["one.txt", "two.txt"], name="files.zip")
51
- ```
52
-
53
- Text and bytes helpers own their temporary file until the share session is closed:
54
-
55
- ```python
56
- with ffl.share_text("hello", name="hello.txt") as session:
57
- print(session.link)
58
- ```
59
-
60
- Optional-value FFL flags use natural Python values. For example, `receipt=True` emits
61
- `--receipt` without a value, while `receipt="me@example.com"` emits the flag with the
62
- address.
63
-
64
- ### Stream a source without a temporary file
65
-
66
- `share_stream()` passes a binary file-like object directly to FFL stdin. It is useful
67
- for database dumps, generated artifacts, and other data that should not first be
68
- materialized as a separate temporary file:
69
-
70
- ```python
71
- with open("backup.tar", "rb") as source:
72
- with ffl.share_stream(source, name="backup.tar") as session:
73
- print(session.link)
74
- ```
75
-
76
- ## Download
77
-
78
- ```python
79
- result = ffl.download("https://example.fastfilelink/...", output_path="download.bin")
80
- print(result.output_path)
81
- print(result.transfer_mode)
82
- ```
83
-
84
- `download()` waits for the foreground FFL process to finish and returns a
85
- `DownloadResult`. For cancellation, progress, or caller-controlled timeouts, use
86
- `start_download()` and manage its `DownloadSession` explicitly.
87
-
88
- ### Stream a download
89
-
90
- `download_stream()` exposes FFL stdout as binary chunks without buffering the whole
91
- file in memory:
92
-
93
- ```python
94
- with ffl.download_stream("https://example.fastfilelink/...") as transfer:
95
- with open("output.bin", "wb") as target:
96
- for chunk in transfer.iter_bytes():
97
- target.write(chunk)
98
-
99
- result = transfer.wait()
100
- ```
101
-
102
- Consume `iter_bytes()` through EOF before calling `wait()`. Calling `wait()` with
103
- unconsumed streamed stdout raises `RuntimeError`; this avoids silently discarding
104
- binary data or deadlocking when the child process fills its stdout pipe.
105
-
106
- ## Authentication secrets
107
-
108
- For shares protected with HTTP Basic Auth, FFL supports `FFL_AUTH_PASSWORD`. Set it in
109
- the application environment and pass only `auth_user` to keep the password out of the
110
- FFL command line:
111
-
112
- ```bash
113
- export FFL_AUTH_PASSWORD='use-your-secret-manager'
114
- ```
115
-
116
- ```powershell
117
- $env:FFL_AUTH_PASSWORD = 'use-your-secret-manager'
118
- ```
119
-
120
- ```python
121
- with ffl.share("release.zip", auth_user="deploy") as session:
122
- print(session.link)
123
- ```
124
-
125
- Do not also pass `auth_password=` when using this pattern: FFL gives the explicit CLI
126
- option precedence over `FFL_AUTH_PASSWORD`. The environment variable applies to the
127
- sharing side; pass download credentials explicitly when downloading a protected link.
128
-
129
- ## Key generation
130
-
131
- ```python
132
- result = ffl.keygen("alice")
133
- print(result.public_key_path)
134
- print(result.private_key_path)
135
- ```
136
-
137
- `keygen()` has a 60-second process timeout and verifies that the key paths reported by
138
- FFL exist.
139
-
140
- ## Version and raw access
141
-
142
- ```python
143
- print(ffl.version())
144
- result = ffl.raw(["download", "--help"])
145
- ```
146
-
147
- `raw()` is the escape hatch for new FFL options or commands that the semantic API has
148
- not adopted yet.
149
-
150
- ## WSL2
151
-
152
- If an operation fails with `TLSError([0x6300])`, WSL may be routing the bundled
153
- `.com` APE through Windows interop. Run the following in WSL, then restart the WSL
154
- session:
155
-
156
- ```bash
157
- sudo sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/WSLInterop'
158
- ```
159
-
160
- ## Updating the FFL binding
161
-
162
- Install APEBind from its source project. When adopting a new `ffl.com`, first inspect the
163
- CLI into a raw discovery file:
164
-
165
- ```bash
166
- ./scripts/inspect.sh /path/to/ffl.com
167
- ```
168
-
169
- This writes `binding/ffl.discovered.apebind.yaml` and automatically applies the hidden
170
- command seeds in `binding/ffl.commands.yaml`. Review the discovered-schema diff, then
171
- manually merge CLI changes into the canonical semantic contract
172
- `binding/ffl.apebind.yaml`. Automatic inspection never overwrites the semantic contract.
173
-
174
- After reviewing the semantic schema, regenerate the low-level binding:
175
-
176
- ```bash
177
- ./scripts/regenerate.sh /path/to/ffl.com
178
- # Windows: .\scripts\regenerate.ps1 D:\ffl.com
179
- ```
180
-
181
- The script invokes APEBind into an isolated temporary project and replaces only these
182
- machine-owned files:
183
-
184
- - `src/ffl/_generated.py`
185
- - `src/ffl/_runtime.py`
186
- - `src/ffl/bin/ffl.com`
187
- - `src/ffl/py.typed`
188
-
189
- It deliberately does not overwrite `client.py`, `models.py`, parsing logic, tests, or the
190
- semantic schema.
@@ -1,4 +0,0 @@
1
- [diffend] Oversized file quarantined before diffing.
2
- name: ffl_python-0.1.4/src/ffl/bin/ffl.com
3
- size: 51721850 bytes
4
- sha256: 0c614cf8b87a1e73fde86b04911a246dd3ca9420819cfe8a05e8edc03146737b
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes