qshare 0.1.0__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.
qshare-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.3
2
+ Name: qshare
3
+ Version: 0.1.0
4
+ Summary: Quickly share local files through a public TryCloudflare tunnel.
5
+ Author: steinvenic
6
+ Author-email: steinvenic <761701732@qq.com>
7
+ Requires-Python: >=3.6
8
+ Description-Content-Type: text/markdown
9
+
10
+ # qshare
11
+
12
+ Quickly share a local file via a public TryCloudflare tunnel.
13
+
14
+ ## Features
15
+
16
+ - Start a temporary local HTTP server on a random free port
17
+ - Detect and avoid occupied ports automatically
18
+ - Create a public TryCloudflare URL for the file
19
+ - Support a default lifetime of 2 hours, adjustable via CLI arguments
20
+ - Run in the background with `--daemon` for unattended usage
21
+ - Compatible with Python 3.6 and newer
22
+ - Ready for uv-managed development and PyPI packaging
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.6+
27
+ - `cloudflared` is downloaded automatically if it is not already installed
28
+
29
+ `cloudflared` does not ship as an official Python package on PyPI. Instead, qshare will automatically download the official Cloudflare tunnel binary when it is missing.
30
+
31
+ If your network is limited or you want to use a mirror, set the environment variable before running qshare:
32
+
33
+ ```bash
34
+ export CLOUDFLARED_DOWNLOAD_URL="https://example.com/mirror/cloudflared-linux-amd64"
35
+ ```
36
+
37
+ If you want to install it manually for any reason, use:
38
+
39
+ - https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
40
+
41
+ ## Install
42
+
43
+ Using uv:
44
+
45
+ ```bash
46
+ uv venv
47
+ source .venv/bin/activate
48
+ uv pip install -e .
49
+ ```
50
+
51
+ Or install from PyPI after publishing:
52
+
53
+ ```bash
54
+ pip install qshare
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ qshare /path/to/file.zip
61
+ qshare /path/to/file.zip --ttl 2h
62
+ qshare /path/to/file.zip --ttl 900
63
+ qshare /path/to/file.zip --port 8000
64
+ qshare /path/to/file.zip --daemon
65
+ qshare /path/to/file.zip --daemon --ttl 1h
66
+ ```
67
+
68
+ The command starts a local HTTP server on a random free port, creates a temporary public URL with TryCloudflare, and keeps the tunnel alive for the configured duration. If `cloudflared` is absent, qshare will fetch the official binary automatically into `~/.local/bin`.
69
+
70
+ Use `--daemon` to launch the share process in the background and exit immediately. This is useful for long-running uploads or starting a tunnel from a service manager or shell script.
71
+
72
+ ## Build for PyPI
73
+
74
+ ```bash
75
+ uv build
76
+ ```
77
+
78
+ Then upload the resulting artifacts from the `dist/` directory to PyPI.
79
+
80
+ ## Notes
81
+
82
+ - The default lifetime is 2 hours.
83
+ - If a port is occupied, qshare automatically selects another free port.
84
+ - `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
85
+ - `--daemon` runs the program in the background and returns immediately.
qshare-0.1.0/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # qshare
2
+
3
+ Quickly share a local file via a public TryCloudflare tunnel.
4
+
5
+ ## Features
6
+
7
+ - Start a temporary local HTTP server on a random free port
8
+ - Detect and avoid occupied ports automatically
9
+ - Create a public TryCloudflare URL for the file
10
+ - Support a default lifetime of 2 hours, adjustable via CLI arguments
11
+ - Run in the background with `--daemon` for unattended usage
12
+ - Compatible with Python 3.6 and newer
13
+ - Ready for uv-managed development and PyPI packaging
14
+
15
+ ## Requirements
16
+
17
+ - Python 3.6+
18
+ - `cloudflared` is downloaded automatically if it is not already installed
19
+
20
+ `cloudflared` does not ship as an official Python package on PyPI. Instead, qshare will automatically download the official Cloudflare tunnel binary when it is missing.
21
+
22
+ If your network is limited or you want to use a mirror, set the environment variable before running qshare:
23
+
24
+ ```bash
25
+ export CLOUDFLARED_DOWNLOAD_URL="https://example.com/mirror/cloudflared-linux-amd64"
26
+ ```
27
+
28
+ If you want to install it manually for any reason, use:
29
+
30
+ - https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
31
+
32
+ ## Install
33
+
34
+ Using uv:
35
+
36
+ ```bash
37
+ uv venv
38
+ source .venv/bin/activate
39
+ uv pip install -e .
40
+ ```
41
+
42
+ Or install from PyPI after publishing:
43
+
44
+ ```bash
45
+ pip install qshare
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ qshare /path/to/file.zip
52
+ qshare /path/to/file.zip --ttl 2h
53
+ qshare /path/to/file.zip --ttl 900
54
+ qshare /path/to/file.zip --port 8000
55
+ qshare /path/to/file.zip --daemon
56
+ qshare /path/to/file.zip --daemon --ttl 1h
57
+ ```
58
+
59
+ The command starts a local HTTP server on a random free port, creates a temporary public URL with TryCloudflare, and keeps the tunnel alive for the configured duration. If `cloudflared` is absent, qshare will fetch the official binary automatically into `~/.local/bin`.
60
+
61
+ Use `--daemon` to launch the share process in the background and exit immediately. This is useful for long-running uploads or starting a tunnel from a service manager or shell script.
62
+
63
+ ## Build for PyPI
64
+
65
+ ```bash
66
+ uv build
67
+ ```
68
+
69
+ Then upload the resulting artifacts from the `dist/` directory to PyPI.
70
+
71
+ ## Notes
72
+
73
+ - The default lifetime is 2 hours.
74
+ - If a port is occupied, qshare automatically selects another free port.
75
+ - `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
76
+ - `--daemon` runs the program in the background and returns immediately.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "qshare"
3
+ version = "0.1.0"
4
+ description = "Quickly share local files through a public TryCloudflare tunnel."
5
+ readme = "README.md"
6
+ requires-python = ">=3.6"
7
+ dependencies = []
8
+
9
+ [[project.authors]]
10
+ name = "steinvenic"
11
+ email = "761701732@qq.com"
12
+
13
+ [project.scripts]
14
+ qshare = "qshare.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.17,<0.13.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=9.1.1"]
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "qshare"
3
+ version = "0.1.0"
4
+ description = "Quickly share local files through a public TryCloudflare tunnel."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "steinvenic", email = "761701732@qq.com" }
8
+ ]
9
+ requires-python = ">=3.6"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ qshare = "qshare.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.12.17,<0.13.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.1.1",
22
+ ]
@@ -0,0 +1,5 @@
1
+ """qshare package."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,336 @@
1
+ import argparse
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ import urllib.request
13
+ import uuid
14
+ from functools import partial
15
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Iterable, Optional, Sequence, Tuple, Union
18
+
19
+ DEFAULT_TTL_SECONDS = 2 * 60 * 60
20
+
21
+
22
+ def parse_duration(value: Union[str, int, float]) -> int:
23
+ """Convert duration strings like 30m, 90s, 2h to seconds."""
24
+ if isinstance(value, (int, float)):
25
+ seconds = int(value)
26
+ if seconds <= 0:
27
+ raise ValueError("Duration must be positive.")
28
+ return seconds
29
+
30
+ text = str(value).strip().lower()
31
+ if not text:
32
+ raise ValueError("Duration is required.")
33
+
34
+ if text.isdigit():
35
+ return int(text)
36
+
37
+ match = re.fullmatch(r"(?P<number>\d+)(?P<unit>[smhd]?)", text)
38
+ if not match:
39
+ raise ValueError(f"Unsupported duration: {value!r}. Use 30m, 90s, 2h, or plain seconds.")
40
+
41
+ number = int(match.group("number"))
42
+ unit = match.group("unit") or "s"
43
+ multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
44
+ seconds = number * multipliers[unit]
45
+ if seconds <= 0:
46
+ raise ValueError("Duration must be greater than zero.")
47
+ return seconds
48
+
49
+
50
+ def find_available_port(
51
+ start_port: int = 20000,
52
+ end_port: int = 65535,
53
+ used_ports: Optional[Iterable[int]] = None,
54
+ ) -> int:
55
+ """Return a free localhost port, avoiding the given ports."""
56
+ blacklisted = set(used_ports or [])
57
+ ports = list(range(start_port, end_port + 1))
58
+ import random
59
+
60
+ random.shuffle(ports)
61
+ for port in ports:
62
+ if port in blacklisted:
63
+ continue
64
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
65
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
66
+ try:
67
+ sock.bind(("127.0.0.1", port))
68
+ return port
69
+ except OSError:
70
+ continue
71
+ raise OSError(f"No free port found between {start_port} and {end_port}.")
72
+
73
+
74
+ def extract_trycloudflare_url(output: str) -> Optional[str]:
75
+ match = re.search(r"https?://[A-Za-z0-9.-]+\.trycloudflare\.com", output)
76
+ if match:
77
+ return match.group(0)
78
+ return None
79
+
80
+
81
+ def build_download_url(base_url: str, file_name: str) -> str:
82
+ cleaned_base = base_url.rstrip("/")
83
+ cleaned_name = file_name.lstrip("/")
84
+ return f"{cleaned_base}/{cleaned_name}"
85
+
86
+
87
+ class ShareHandler(SimpleHTTPRequestHandler):
88
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
89
+ return
90
+
91
+
92
+ def start_local_http_server(file_path: Path, port: int) -> Tuple[ThreadingHTTPServer, threading.Thread]:
93
+ if not file_path.exists():
94
+ raise FileNotFoundError(f"File does not exist: {file_path}")
95
+ if not file_path.is_file():
96
+ raise ValueError(f"Path is not a file: {file_path}")
97
+
98
+ server = ThreadingHTTPServer(("127.0.0.1", port), partial(ShareHandler, directory=str(file_path.parent)))
99
+ thread = threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True)
100
+ thread.start()
101
+ return server, thread
102
+
103
+
104
+ def stop_local_http_server(server: ThreadingHTTPServer) -> None:
105
+ try:
106
+ server.shutdown()
107
+ except Exception:
108
+ pass
109
+ try:
110
+ server.server_close()
111
+ except Exception:
112
+ pass
113
+
114
+
115
+ def get_cloudflared_download_url() -> str:
116
+ override = os.getenv("CLOUDFLARED_DOWNLOAD_URL")
117
+ if override:
118
+ return override.strip()
119
+
120
+ system = platform.system().lower()
121
+ machine = platform.machine().lower()
122
+ mapping = {
123
+ ("linux", "x86_64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
124
+ ("linux", "amd64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
125
+ ("linux", "aarch64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
126
+ ("linux", "arm64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
127
+ }
128
+ key = (system, machine)
129
+ if key not in mapping:
130
+ raise RuntimeError(
131
+ f"Unsupported platform for automatic cloudflared install: {system}/{machine}. "
132
+ "Please install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
133
+ )
134
+ return mapping[key]
135
+
136
+
137
+ def install_cloudflared_binary() -> str:
138
+ target_dir = Path.home() / ".local" / "bin"
139
+ target_dir.mkdir(parents=True, exist_ok=True)
140
+ target = target_dir / "cloudflared"
141
+
142
+ if target.exists():
143
+ return str(target)
144
+
145
+ url = get_cloudflared_download_url()
146
+ try:
147
+ urllib.request.urlretrieve(url, str(target))
148
+ target.chmod(0o755)
149
+ return str(target)
150
+ except Exception:
151
+ if target.exists():
152
+ target.unlink()
153
+ raise
154
+
155
+
156
+ def ensure_cloudflared() -> str:
157
+ binary_path = shutil.which("cloudflared")
158
+ if binary_path:
159
+ return binary_path
160
+
161
+ user_bin = Path.home() / ".local" / "bin" / "cloudflared"
162
+ if user_bin.exists():
163
+ return str(user_bin)
164
+
165
+ installed = install_cloudflared_binary()
166
+ os.environ["PATH"] = str(Path(installed).parent) + os.pathsep + os.environ.get("PATH", "")
167
+ return installed
168
+
169
+
170
+ def launch_trycloudflare_tunnel(local_url: str, timeout_seconds: int) -> Tuple[subprocess.Popen, str]:
171
+ cloudflared_path = ensure_cloudflared()
172
+ logfile = Path(tempfile.gettempdir()) / f"qshare-{uuid.uuid4().hex}.log"
173
+ process = subprocess.Popen(
174
+ [cloudflared_path, "tunnel", "--url", local_url, "--no-autoupdate", "--logfile", str(logfile)],
175
+ stdout=subprocess.PIPE,
176
+ stderr=subprocess.STDOUT,
177
+ text=True,
178
+ bufsize=1,
179
+ )
180
+
181
+ if process.stdout is None:
182
+ raise RuntimeError("Unable to capture cloudflared output.")
183
+
184
+ deadline = time.monotonic() + timeout_seconds
185
+ while time.monotonic() < deadline:
186
+ line = process.stdout.readline()
187
+ if not line:
188
+ if process.poll() is not None:
189
+ output = ""
190
+ try:
191
+ output = process.stdout.read()
192
+ except Exception:
193
+ output = ""
194
+ raise RuntimeError(f"cloudflared exited before returning a tunnel URL.\n{output}")
195
+ time.sleep(0.2)
196
+ continue
197
+
198
+ url = extract_trycloudflare_url(line)
199
+ if url:
200
+ return process, url
201
+
202
+ if process.poll() is None:
203
+ try:
204
+ process.terminate()
205
+ process.wait(timeout=5)
206
+ except Exception:
207
+ pass
208
+
209
+ raise TimeoutError(f"Timed out waiting for a public TryCloudflare URL in {timeout_seconds} seconds.")
210
+
211
+
212
+ def stop_tunnel(process: subprocess.Popen) -> None:
213
+ if process.poll() is None:
214
+ try:
215
+ process.terminate()
216
+ process.wait(timeout=5)
217
+ except Exception:
218
+ try:
219
+ process.kill()
220
+ except Exception:
221
+ pass
222
+
223
+
224
+ def run_share(file_path: str, ttl_seconds: int = DEFAULT_TTL_SECONDS, port: Optional[int] = None) -> int:
225
+ source = Path(file_path).expanduser().resolve()
226
+ if not source.exists():
227
+ raise FileNotFoundError(f"File not found: {source}")
228
+ if not source.is_file():
229
+ raise ValueError(f"Not a file: {source}")
230
+
231
+ resolved_port = port or find_available_port()
232
+ server, server_thread = start_local_http_server(source, resolved_port)
233
+ local_url = f"http://127.0.0.1:{resolved_port}/{source.name}"
234
+
235
+ print(f"Local file: {source}")
236
+ print(f"Local HTTP URL: {local_url}")
237
+ print(f"Waiting for public TryCloudflare URL (ttl={ttl_seconds}s)...")
238
+
239
+ tunnel_process = None
240
+ tunnel_url = None
241
+ stop_event = threading.Event()
242
+
243
+ def stop_all() -> None:
244
+ stop_event.set()
245
+ if tunnel_process is not None:
246
+ stop_tunnel(tunnel_process)
247
+ stop_local_http_server(server)
248
+ if server_thread.is_alive():
249
+ server_thread.join(timeout=2)
250
+
251
+ timer = threading.Timer(ttl_seconds, stop_all)
252
+ timer.daemon = True
253
+ timer.start()
254
+
255
+ try:
256
+ tunnel_process, tunnel_url = launch_trycloudflare_tunnel(local_url, timeout_seconds=min(ttl_seconds, 30))
257
+ public_file_url = build_download_url(tunnel_url, source.name)
258
+ print(f"Public file URL: {public_file_url}")
259
+ while not stop_event.is_set() and tunnel_process.poll() is None:
260
+ time.sleep(0.5)
261
+ except KeyboardInterrupt:
262
+ print("\nInterrupted by user.")
263
+ stop_all()
264
+ return 0
265
+ except Exception:
266
+ stop_all()
267
+ raise
268
+ finally:
269
+ timer.cancel()
270
+ stop_all()
271
+
272
+ return 0
273
+
274
+
275
+ def build_parser() -> argparse.ArgumentParser:
276
+ parser = argparse.ArgumentParser(
277
+ description="Quickly share a local file over a public TryCloudflare tunnel.",
278
+ prog="qshare",
279
+ )
280
+ parser.add_argument("file", help="Local file to share publicly.")
281
+ parser.add_argument(
282
+ "-t",
283
+ "--ttl",
284
+ default="2h",
285
+ help="How long the tunnel remains active. Examples: 30m, 90s, 2h, or 600 (seconds).",
286
+ )
287
+ parser.add_argument(
288
+ "-p",
289
+ "--port",
290
+ type=int,
291
+ default=None,
292
+ help="Optional fixed port for the local HTTP server. A random free port is used when omitted.",
293
+ )
294
+ parser.add_argument(
295
+ "-d",
296
+ "--daemon",
297
+ action="store_true",
298
+ help="Run the share process in the background and exit immediately.",
299
+ )
300
+ return parser
301
+
302
+
303
+ def main(argv: Optional[Sequence[str]] = None) -> int:
304
+ parser = build_parser()
305
+ args = parser.parse_args(argv)
306
+
307
+ if args.daemon:
308
+ cmd = [sys.executable, os.path.abspath(sys.argv[0]), args.file]
309
+ if args.ttl != "2h":
310
+ cmd.extend(["--ttl", str(args.ttl)])
311
+ if args.port is not None:
312
+ cmd.extend(["--port", str(args.port)])
313
+ with open(os.devnull, "wb") as devnull:
314
+ subprocess.Popen(
315
+ cmd,
316
+ stdin=devnull,
317
+ stdout=devnull,
318
+ stderr=devnull,
319
+ close_fds=True,
320
+ start_new_session=True,
321
+ )
322
+ return 0
323
+
324
+ try:
325
+ ttl_seconds = parse_duration(args.ttl)
326
+ return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
327
+ except KeyboardInterrupt:
328
+ print("\nStopped.")
329
+ return 130
330
+ except Exception as exc: # pragma: no cover - CLI-level output
331
+ print(f"qshare error: {exc}", file=sys.stderr)
332
+ return 1
333
+
334
+
335
+ if __name__ == "__main__":
336
+ raise SystemExit(main())