fileclip 0.1.0__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.
fileclip/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("fileclip")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
fileclip/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entry point for `python -m fileclip`."""
2
+
3
+ from fileclip.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
fileclip/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """Command-line interface for FileClip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+ import webbrowser
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ import uvicorn
14
+
15
+ from fileclip.server import LaunchConfig, create_app
16
+
17
+ DEFAULT_HOST = "127.0.0.1"
18
+ DEFAULT_PORT = 0
19
+
20
+ app = typer.Typer(
21
+ add_completion=False,
22
+ help="Run the local FileClip clipboard-envelope web app.",
23
+ no_args_is_help=False,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ServerLaunch:
29
+ """Resolved server launch details."""
30
+
31
+ host: str
32
+ requested_port: int
33
+ port: int
34
+ url: str
35
+ sockets: list[socket.socket] | None = None
36
+
37
+
38
+ def is_loopback_host(host: str) -> bool:
39
+ """Return whether host is an accepted loopback bind address."""
40
+
41
+ normalized = host.strip().lower()
42
+ if normalized == "localhost":
43
+ return True
44
+ try:
45
+ return ipaddress.ip_address(normalized).is_loopback
46
+ except ValueError:
47
+ return False
48
+
49
+
50
+ def format_host_for_url(host: str) -> str:
51
+ """Format a host for use in an HTTP URL."""
52
+
53
+ if ":" in host and not host.startswith("["):
54
+ return f"[{host}]"
55
+ return host
56
+
57
+
58
+ def build_app_url(host: str, port: int) -> str:
59
+ """Build the browser URL for a FileClip server."""
60
+
61
+ return f"http://{format_host_for_url(host)}:{port}/"
62
+
63
+
64
+ def _bind_socket(host: str) -> socket.socket:
65
+ """Bind and listen on an available port for the requested host."""
66
+
67
+ last_error: OSError | None = None
68
+ for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
69
+ host,
70
+ 0,
71
+ type=socket.SOCK_STREAM,
72
+ ):
73
+ sock = socket.socket(family, socktype, proto)
74
+ try:
75
+ sock.bind(sockaddr)
76
+ sock.listen()
77
+ except OSError as exc:
78
+ last_error = exc
79
+ sock.close()
80
+ continue
81
+ return sock
82
+ if last_error is not None:
83
+ raise last_error
84
+ raise OSError(f"Could not resolve a bind address for {host!r}.")
85
+
86
+
87
+ def close_sockets(sockets: list[socket.socket] | None) -> None:
88
+ """Close sockets that were pre-bound for uvicorn."""
89
+
90
+ if sockets is None:
91
+ return
92
+ for sock in sockets:
93
+ try:
94
+ sock.close()
95
+ except OSError:
96
+ pass
97
+
98
+
99
+ def prepare_server_launch(host: str, port: int) -> ServerLaunch:
100
+ """Validate bind options and resolve the actual browser URL."""
101
+
102
+ if not is_loopback_host(host):
103
+ raise typer.BadParameter(
104
+ "Loopback-only host required.",
105
+ param_hint="--host",
106
+ )
107
+ if port < 0 or port > 65535:
108
+ raise typer.BadParameter(
109
+ "Port must be between 0 and 65535.",
110
+ param_hint="--port",
111
+ )
112
+ if port == 0:
113
+ sock = _bind_socket(host)
114
+ actual_port = int(sock.getsockname()[1])
115
+ return ServerLaunch(
116
+ host=host,
117
+ requested_port=port,
118
+ port=actual_port,
119
+ url=build_app_url(host, actual_port),
120
+ sockets=[sock],
121
+ )
122
+ return ServerLaunch(
123
+ host=host,
124
+ requested_port=port,
125
+ port=port,
126
+ url=build_app_url(host, port),
127
+ )
128
+
129
+
130
+ def _prompt_passphrase() -> str:
131
+ """Prompt for a passphrase without echoing it to the terminal."""
132
+
133
+ return str(typer.prompt("Passphrase", hide_input=True))
134
+
135
+
136
+ def resolve_passphrase(
137
+ passphrase: str | None,
138
+ passphrase_prompt: bool,
139
+ prompt_func: Callable[[], str] = _prompt_passphrase,
140
+ ) -> str | None:
141
+ """Resolve passphrase options into the configured passphrase."""
142
+
143
+ if passphrase is not None and passphrase_prompt:
144
+ raise typer.BadParameter(
145
+ "Use either --passphrase or --passphrase-prompt, not both.",
146
+ param_hint="--passphrase",
147
+ )
148
+ resolved = prompt_func() if passphrase_prompt else passphrase
149
+ if resolved == "":
150
+ raise typer.BadParameter(
151
+ "Passphrase cannot be empty.",
152
+ param_hint="--passphrase",
153
+ )
154
+ return resolved
155
+
156
+
157
+ def run_local_server(
158
+ *,
159
+ host: str,
160
+ port: int,
161
+ open_browser: bool,
162
+ passphrase: str | None,
163
+ passphrase_prompt: bool,
164
+ browser_open: Callable[[str], bool] = webbrowser.open,
165
+ ) -> None:
166
+ """Run the local FileClip server."""
167
+
168
+ resolved_passphrase = resolve_passphrase(passphrase, passphrase_prompt)
169
+ launch = prepare_server_launch(host, port)
170
+ asgi_app = create_app(LaunchConfig(passphrase=resolved_passphrase))
171
+ uvicorn_config = uvicorn.Config(
172
+ asgi_app,
173
+ host=launch.host,
174
+ port=launch.port,
175
+ access_log=False,
176
+ log_level="info",
177
+ )
178
+ server = uvicorn.Server(uvicorn_config)
179
+ try:
180
+ typer.echo(f"Serving FileClip at {launch.url}")
181
+ if open_browser:
182
+ browser_open(launch.url)
183
+ server.run(sockets=launch.sockets)
184
+ finally:
185
+ close_sockets(launch.sockets)
186
+
187
+
188
+ @app.command()
189
+ def root(
190
+ host: Annotated[
191
+ str,
192
+ typer.Option("--host", help="Loopback host to bind."),
193
+ ] = DEFAULT_HOST,
194
+ port: Annotated[
195
+ int,
196
+ typer.Option("--port", min=0, max=65535, help="Port to bind; 0 chooses one."),
197
+ ] = DEFAULT_PORT,
198
+ open_browser: Annotated[
199
+ bool,
200
+ typer.Option("--open/--no-open", help="Open the browser after launch."),
201
+ ] = True,
202
+ passphrase: Annotated[
203
+ str | None,
204
+ typer.Option("--passphrase", help="Enable encrypted mode with this passphrase."),
205
+ ] = None,
206
+ passphrase_prompt: Annotated[
207
+ bool,
208
+ typer.Option(
209
+ "--passphrase-prompt",
210
+ help="Prompt for a passphrase without echoing it.",
211
+ ),
212
+ ] = False,
213
+ ) -> None:
214
+ """Start the local FileClip browser app."""
215
+
216
+ run_local_server(
217
+ host=host,
218
+ port=port,
219
+ open_browser=open_browser,
220
+ passphrase=passphrase,
221
+ passphrase_prompt=passphrase_prompt,
222
+ )
223
+
224
+
225
+ def main() -> None:
226
+ """Run the Typer application."""
227
+
228
+ app()
fileclip/server.py ADDED
@@ -0,0 +1,88 @@
1
+ """FastAPI application factory for FileClip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from fileclip import __version__
14
+
15
+ APP_NAME = "fileclip"
16
+ SCHEMA_VERSION = 1
17
+ PLAIN_MODE = "plain"
18
+ ENCRYPTED_MODE = "encrypted"
19
+ CONFIG_CACHE_CONTROL = "no-store"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LaunchConfig:
24
+ """Runtime configuration exposed to the browser app."""
25
+
26
+ passphrase: str | None = None
27
+ app_name: str = APP_NAME
28
+ schema: int = SCHEMA_VERSION
29
+ version: str = __version__
30
+
31
+ @property
32
+ def mode(self) -> str:
33
+ """Return the public launch mode."""
34
+
35
+ if self.passphrase is None:
36
+ return PLAIN_MODE
37
+ return ENCRYPTED_MODE
38
+
39
+ def as_public_config(self) -> dict[str, Any]:
40
+ """Return the JSON-safe browser launch configuration."""
41
+
42
+ config: dict[str, Any] = {
43
+ "app": self.app_name,
44
+ "version": self.version,
45
+ "schema": self.schema,
46
+ "mode": self.mode,
47
+ }
48
+ if self.passphrase is not None:
49
+ config["passphrase"] = self.passphrase
50
+ return config
51
+
52
+
53
+ def static_dir() -> Path:
54
+ """Return the packaged static asset directory."""
55
+
56
+ return Path(__file__).with_name("static")
57
+
58
+
59
+ def create_app(config: LaunchConfig | None = None) -> FastAPI:
60
+ """Create the local FileClip ASGI application."""
61
+
62
+ launch_config = config or LaunchConfig()
63
+ assets = static_dir()
64
+ app = FastAPI(
65
+ title="FileClip",
66
+ docs_url=None,
67
+ redoc_url=None,
68
+ openapi_url=None,
69
+ )
70
+ app.state.launch_config = launch_config
71
+
72
+ @app.get("/", include_in_schema=False)
73
+ def index() -> FileResponse:
74
+ """Serve the browser app shell."""
75
+
76
+ return FileResponse(assets / "index.html", media_type="text/html")
77
+
78
+ @app.get("/config.json", include_in_schema=False)
79
+ def config_json() -> JSONResponse:
80
+ """Serve launch configuration without allowing browser caching."""
81
+
82
+ return JSONResponse(
83
+ launch_config.as_public_config(),
84
+ headers={"Cache-Control": CONFIG_CACHE_CONTROL},
85
+ )
86
+
87
+ app.mount("/static", StaticFiles(directory=assets), name="static")
88
+ return app