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/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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+