qshare 0.2.0__py3-none-win_amd64.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.
qshare/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """qshare package."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
qshare/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: qshare/_binaries/cloudflared.exe
3
+ size: 54976432 bytes
4
+ sha256: 2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712
qshare/cli.py ADDED
@@ -0,0 +1,464 @@
1
+ import argparse
2
+ import os
3
+ import re
4
+ import signal
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ import uuid
13
+ import qrcode
14
+ from functools import partial
15
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Iterable, List, Optional, 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
+ for match in re.finditer(r"https://(?P<host>[A-Za-z0-9-]+\.trycloudflare\.com)", output, re.IGNORECASE):
76
+ if match.group("host").lower() != "api.trycloudflare.com":
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
+ def print_qr_code(url: str) -> None:
88
+ qr = qrcode.QRCode(
89
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
90
+ border=1,
91
+ )
92
+ qr.add_data(url)
93
+ qr.make(fit=True)
94
+ print("Scan this QR code to open the public file URL:")
95
+ # Two QR rows per terminal row keeps the code compact and scannable.
96
+ qr.print_ascii(invert=True)
97
+
98
+
99
+ class ShareHandler(SimpleHTTPRequestHandler):
100
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
101
+ return
102
+
103
+
104
+ def start_local_http_server(file_path: Path, port: int) -> Tuple[ThreadingHTTPServer, threading.Thread]:
105
+ if not file_path.exists():
106
+ raise FileNotFoundError(f"File does not exist: {file_path}")
107
+ if not file_path.is_file():
108
+ raise ValueError(f"Path is not a file: {file_path}")
109
+
110
+ server = ThreadingHTTPServer(("127.0.0.1", port), partial(ShareHandler, directory=str(file_path.parent)))
111
+ thread = threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True)
112
+ thread.start()
113
+ return server, thread
114
+
115
+
116
+ def stop_local_http_server(server: ThreadingHTTPServer) -> None:
117
+ try:
118
+ server.shutdown()
119
+ except Exception:
120
+ pass
121
+ try:
122
+ server.server_close()
123
+ except Exception:
124
+ pass
125
+
126
+
127
+ def get_bundled_cloudflared_binary() -> Path:
128
+ name = "cloudflared.exe" if os.name == "nt" else "cloudflared"
129
+ bundled = Path(__file__).resolve().parent / "_binaries" / name
130
+ if not bundled.is_file():
131
+ raise RuntimeError(
132
+ "This qshare installation has no bundled cloudflared binary for this platform. "
133
+ "Install qshare from PyPI on a supported platform, or install cloudflared yourself and add it to PATH."
134
+ )
135
+ return bundled
136
+
137
+
138
+ def install_cloudflared_binary() -> str:
139
+ target_dir = Path.home() / ".local" / "bin"
140
+ target_dir.mkdir(parents=True, exist_ok=True)
141
+ target = target_dir / ("cloudflared.exe" if os.name == "nt" else "cloudflared")
142
+
143
+ if target.exists():
144
+ return str(target)
145
+
146
+ try:
147
+ print("cloudflared was not found. Installing the binary bundled with qshare...")
148
+ shutil.copyfile(str(get_bundled_cloudflared_binary()), str(target))
149
+ if os.name != "nt":
150
+ target.chmod(0o755)
151
+ return str(target)
152
+ except Exception:
153
+ if target.exists():
154
+ target.unlink()
155
+ raise
156
+
157
+
158
+ def ensure_cloudflared() -> str:
159
+ binary_path = shutil.which("cloudflared")
160
+ if binary_path:
161
+ return binary_path
162
+
163
+ user_bin = Path.home() / ".local" / "bin" / ("cloudflared.exe" if os.name == "nt" else "cloudflared")
164
+ if user_bin.exists():
165
+ return str(user_bin)
166
+
167
+ installed = install_cloudflared_binary()
168
+ os.environ["PATH"] = str(Path(installed).parent) + os.pathsep + os.environ.get("PATH", "")
169
+ return installed
170
+
171
+
172
+ def launch_trycloudflare_tunnel(local_url: str, timeout_seconds: int) -> Tuple[subprocess.Popen, str]:
173
+ cloudflared_path = ensure_cloudflared()
174
+ deadline = time.monotonic() + timeout_seconds
175
+ for attempt in range(3):
176
+ logfile = Path(tempfile.gettempdir()) / f"qshare-{uuid.uuid4().hex}.log"
177
+ kwargs = {
178
+ "stdout": subprocess.PIPE,
179
+ "stderr": subprocess.STDOUT,
180
+ "universal_newlines": True,
181
+ "bufsize": 1,
182
+ }
183
+ if os.name == "nt":
184
+ kwargs["creationflags"] = (
185
+ getattr(subprocess, "CREATE_NO_WINDOW", 0)
186
+ | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
187
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
188
+ )
189
+ process = subprocess.Popen(
190
+ [cloudflared_path, "tunnel", "--url", local_url, "--no-autoupdate", "--logfile", str(logfile)],
191
+ **kwargs,
192
+ )
193
+ if process.stdout is None:
194
+ raise RuntimeError("Unable to capture cloudflared output.")
195
+
196
+ attempt_deadline = min(deadline, time.monotonic() + 10)
197
+ while time.monotonic() < attempt_deadline:
198
+ line = process.stdout.readline()
199
+ if line:
200
+ url = extract_trycloudflare_url(line)
201
+ if url:
202
+ return process, url
203
+ continue
204
+ if process.poll() is not None:
205
+ break
206
+ time.sleep(0.2)
207
+
208
+ stop_tunnel(process)
209
+ if time.monotonic() < deadline and attempt < 2:
210
+ print("Cloudflare did not provide a valid public URL; retrying...")
211
+
212
+ raise TimeoutError(f"Timed out waiting for a valid public TryCloudflare URL in {timeout_seconds} seconds.")
213
+
214
+
215
+ def stop_tunnel(process: subprocess.Popen) -> None:
216
+ if process.poll() is None:
217
+ try:
218
+ process.terminate()
219
+ process.wait(timeout=5)
220
+ except Exception:
221
+ try:
222
+ process.kill()
223
+ except Exception:
224
+ pass
225
+
226
+
227
+ class DetachedTunnelProcess:
228
+ """Minimal Popen-compatible handle for an inherited orphaned tunnel."""
229
+
230
+ def __init__(self, pid: int) -> None:
231
+ self.pid = pid
232
+
233
+ def poll(self) -> Optional[int]:
234
+ try:
235
+ os.kill(self.pid, 0)
236
+ except OSError:
237
+ return 0
238
+ return None
239
+
240
+ def terminate(self) -> None:
241
+ os.kill(self.pid, signal.SIGTERM)
242
+
243
+ def wait(self, timeout: Optional[float] = None) -> int:
244
+ deadline = None if timeout is None else time.monotonic() + timeout
245
+ while self.poll() is None:
246
+ if deadline is not None and time.monotonic() >= deadline:
247
+ raise subprocess.TimeoutExpired(["cloudflared"], timeout)
248
+ time.sleep(0.1)
249
+ return 0
250
+
251
+ def kill(self) -> None:
252
+ os.kill(self.pid, signal.SIGKILL)
253
+
254
+
255
+ def run_share(file_path: str, ttl_seconds: int = DEFAULT_TTL_SECONDS, port: Optional[int] = None) -> int:
256
+ source = Path(file_path).expanduser().resolve()
257
+ if not source.exists():
258
+ raise FileNotFoundError(f"File not found: {source}")
259
+ if not source.is_file():
260
+ raise ValueError(f"Not a file: {source}")
261
+
262
+ resolved_port = port or find_available_port()
263
+ server, server_thread = start_local_http_server(source, resolved_port)
264
+ local_url = f"http://127.0.0.1:{resolved_port}/{source.name}"
265
+
266
+ print(f"Local file: {source}")
267
+ print(f"Local HTTP URL: {local_url}")
268
+ print(f"Waiting for public TryCloudflare URL (ttl={ttl_seconds}s)...")
269
+
270
+ tunnel_process = None
271
+ tunnel_url = None
272
+ stop_event = threading.Event()
273
+
274
+ detached = False
275
+
276
+ def stop_all() -> None:
277
+ stop_event.set()
278
+ if tunnel_process is not None:
279
+ stop_tunnel(tunnel_process)
280
+ stop_local_http_server(server)
281
+ if server_thread.is_alive():
282
+ server_thread.join(timeout=2)
283
+
284
+ def expire() -> None:
285
+ if not detached:
286
+ print("TTL expired; the public share has stopped.", flush=True)
287
+ stop_all()
288
+
289
+ timer = threading.Timer(ttl_seconds, expire)
290
+ timer.daemon = True
291
+ timer.start()
292
+
293
+ try:
294
+ tunnel_process, tunnel_url = launch_trycloudflare_tunnel(local_url, timeout_seconds=min(ttl_seconds, 30))
295
+ public_file_url = build_download_url(tunnel_url, source.name)
296
+ print("\n" + "=" * 72)
297
+ print(f"PUBLIC FILE URL: {public_file_url}")
298
+ print("=" * 72 + "\n", flush=True)
299
+ print_qr_code(public_file_url)
300
+ # A detached child is already the background worker. Prompting it
301
+ # again (with stdin connected to DEVNULL) makes it take the
302
+ # foreground path accidentally and, more importantly, used to make
303
+ # the hand-off look as if the original service had simply died.
304
+ if os.name != "nt" and os.environ.get("QSHARE_BACKGROUND_CHILD") != "1" and ask_background_mode():
305
+ handoff = detach_existing_process(server)
306
+ if handoff is True:
307
+ # Parent exits, while the child keeps the inherited server
308
+ # socket and cloudflared process (therefore the same URL).
309
+ detached = True
310
+ return 0
311
+ if handoff is None:
312
+ # Windows cannot safely detach this interpreter from its
313
+ # console while retaining the inherited server/tunnel. Keep
314
+ # this process running instead of launching a replacement
315
+ # qshare (which would change the tunnel URL).
316
+ if os.name == "nt":
317
+ detached = True
318
+ print("Running in background mode; keep this window open to retain the same URL.")
319
+ # Fall through to the normal lifetime loop.
320
+ # Other non-fork platforms retain the independent-worker
321
+ # fallback.
322
+ detached = True
323
+ background_argv = [str(source), "--ttl", str(ttl_seconds)]
324
+ if port is not None:
325
+ background_argv.extend(["--port", str(port)])
326
+ return start_background_process(background_argv)
327
+ # Child: the pre-fork timer thread no longer exists, so recreate
328
+ # it before entering the normal lifetime loop.
329
+ detached = True
330
+ tunnel_process = DetachedTunnelProcess(tunnel_process.pid)
331
+ timer = threading.Timer(ttl_seconds, expire)
332
+ timer.daemon = True
333
+ timer.start()
334
+ while not stop_event.is_set() and tunnel_process.poll() is None:
335
+ time.sleep(0.5)
336
+ except KeyboardInterrupt:
337
+ print("\nInterrupted by user.")
338
+ stop_all()
339
+ return 0
340
+ except Exception:
341
+ stop_all()
342
+ raise
343
+ finally:
344
+ timer.cancel()
345
+ if not detached:
346
+ stop_all()
347
+
348
+ return 0
349
+
350
+
351
+ def should_run_in_background(answer: str) -> bool:
352
+ return str(answer or "").strip().lower() == "d"
353
+
354
+
355
+ def build_parser() -> argparse.ArgumentParser:
356
+ parser = argparse.ArgumentParser(
357
+ description="Quickly share a local file over a public TryCloudflare tunnel.",
358
+ prog="qshare",
359
+ )
360
+ parser.add_argument("file", help="Local file to share publicly.")
361
+ parser.add_argument(
362
+ "-t",
363
+ "--ttl",
364
+ default="2h",
365
+ help="How long the tunnel remains active. Examples: 2h, 30m, 90s, or 600 (seconds).",
366
+ )
367
+ parser.add_argument(
368
+ "-p",
369
+ "--port",
370
+ type=int,
371
+ default=None,
372
+ help="Optional fixed port for the local HTTP server. A random free port is used when omitted.",
373
+ )
374
+ return parser
375
+
376
+
377
+ def start_background_process(argv: List[str]) -> int:
378
+ cmd = [sys.executable, "-m", "qshare"] + argv
379
+ env = os.environ.copy()
380
+ env["QSHARE_BACKGROUND_CHILD"] = "1"
381
+ kwargs = {
382
+ "stdin": subprocess.DEVNULL,
383
+ # Keep the child's status and (most importantly) its replacement
384
+ # public URL visible to the user. The parent URL belongs to the
385
+ # short-lived process and cannot remain valid after hand-off.
386
+ "stdout": None,
387
+ "stderr": None,
388
+ "env": env,
389
+ }
390
+ if os.name == "nt":
391
+ creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
392
+ kwargs["creationflags"] = creationflags
393
+ else:
394
+ kwargs["start_new_session"] = True
395
+ subprocess.Popen(cmd, **kwargs)
396
+ print("qshare started in background.")
397
+ return 0
398
+
399
+
400
+ def detach_existing_process(server: ThreadingHTTPServer) -> Optional[bool]:
401
+ """Detach while retaining the existing HTTP socket and tunnel process.
402
+
403
+ Returns True in the short-lived parent, False in the detached child, and
404
+ None on platforms without fork support.
405
+ """
406
+ if os.name == "nt":
407
+ return None
408
+ if not hasattr(os, "fork"):
409
+ return None
410
+ child_pid = os.fork()
411
+ if child_pid:
412
+ return True
413
+
414
+ # The serving thread does not survive fork. Reuse the inherited socket
415
+ # and start a replacement thread; the cloudflared Popen process is also
416
+ # inherited, so its public hostname remains unchanged.
417
+ os.setsid()
418
+ try:
419
+ with open(os.devnull, "rb") as null_in, open(os.devnull, "ab") as null_out:
420
+ os.dup2(null_in.fileno(), sys.stdin.fileno())
421
+ os.dup2(null_out.fileno(), sys.stdout.fileno())
422
+ os.dup2(null_out.fileno(), sys.stderr.fileno())
423
+ except (OSError, ValueError):
424
+ pass
425
+ threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True).start()
426
+ return False
427
+
428
+
429
+ def ask_background_mode() -> bool:
430
+ try:
431
+ answer = input("Run in background? Press 'd' to detach, or press Enter to keep in the foreground: ")
432
+ except EOFError:
433
+ return False
434
+ return should_run_in_background(answer)
435
+
436
+
437
+ def main(argv: Optional[List[str]] = None) -> int:
438
+ parser = build_parser()
439
+ args = parser.parse_args(argv)
440
+
441
+ if os.environ.get("QSHARE_BACKGROUND_CHILD") == "1":
442
+ try:
443
+ ttl_seconds = parse_duration(args.ttl)
444
+ return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
445
+ except KeyboardInterrupt:
446
+ print("\nStopped.")
447
+ return 130
448
+ except Exception as exc: # pragma: no cover - CLI-level output
449
+ print(f"qshare error: {exc}", file=sys.stderr)
450
+ return 1
451
+
452
+ try:
453
+ ttl_seconds = parse_duration(args.ttl)
454
+ return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
455
+ except KeyboardInterrupt:
456
+ print("\nStopped.")
457
+ return 130
458
+ except Exception as exc: # pragma: no cover - CLI-level output
459
+ print(f"qshare error: {exc}", file=sys.stderr)
460
+ return 1
461
+
462
+
463
+ if __name__ == "__main__":
464
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.3
2
+ Name: qshare
3
+ Version: 0.2.0
4
+ Summary: Quickly share local files through a public TryCloudflare tunnel.
5
+ Author: steinvenic
6
+ Author-email: steinvenic <761701732@qq.com>
7
+ Requires-Dist: qrcode==7.3
8
+ Requires-Python: >=3.6
9
+ Description-Content-Type: text/markdown
10
+
11
+ # qshare
12
+
13
+ Quickly share a local file via a public TryCloudflare tunnel.
14
+
15
+ ## Features
16
+
17
+ - Start a temporary local HTTP server on a random free port
18
+ - Detect and avoid occupied ports automatically
19
+ - Create a public TryCloudflare URL for the file
20
+ - Support a default lifetime of 2 hours, adjustable via CLI arguments
21
+ - Works on Linux, Windows, and macOS with a platform-specific bundled cloudflared binary
22
+ - Ready for uv-managed development and PyPI packaging
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.6+
27
+ - qshare platform wheels include the matching official `cloudflared` binary. qshare never downloads a binary at runtime; update qshare from PyPI to receive an updated bundled binary.
28
+
29
+ On a platform without a matching wheel, install `cloudflared` manually and add it to `PATH`.
30
+
31
+ ## Install
32
+
33
+ Using uv:
34
+
35
+ ```bash
36
+ uv venv
37
+ source .venv/bin/activate
38
+ uv pip install -e .
39
+ ```
40
+
41
+ Or install from PyPI after publishing:
42
+
43
+ ```bash
44
+ pip install qshare
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```bash
50
+ qshare /path/to/file.zip
51
+ qshare /path/to/file.zip --ttl 2h
52
+ qshare /path/to/file.zip --ttl 900
53
+ qshare /path/to/file.zip --port 8000
54
+ ```
55
+
56
+ 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 from `PATH`, qshare installs the binary bundled in the matching PyPI wheel into `~/.local/bin`. Platform wheels are available for Cloudflare's Linux (x86, x86_64, ARM, ARMHF, ARM64), Windows (x86, x86_64), and macOS (Intel, Apple Silicon) releases.
57
+
58
+ After the public URL is printed prominently, qshare displays a terminal QR code for the same link. qshare accepts only a valid share hostname such as `https://department-specialists-excellence-savings.trycloudflare.com`; Cloudflare API URLs are rejected and the tunnel is retried. On Unix-like systems it then prompts: `Run in background? Press 'd' to detach, or press Enter to keep in the foreground.` Windows always runs in the foreground. When the TTL expires in foreground mode, qshare prints a notification and stops the share.
59
+
60
+ ## Build for PyPI
61
+
62
+ ```bash
63
+ uv build
64
+ python scripts/build_platform_wheels.py --assets-dir /path/to/cloudflared-assets
65
+ ```
66
+
67
+ Then upload the resulting artifacts from the `dist/` directory to PyPI.
68
+
69
+ ## Notes
70
+
71
+ - The default lifetime is 2 hours.
72
+ - If a port is occupied, qshare automatically selects another free port.
73
+ - `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
74
+ - Background mode is triggered by entering `d` at the interactive prompt after the public URL appears.
@@ -0,0 +1,10 @@
1
+ qshare-0.2.0.dist-info/,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ qshare-0.2.0.dist-info/METADATA,sha256=qJD28ujsZV8UZ7euyDrHgJwMUO1Zt4L0e4WqQCmZ1Ik,2836
3
+ qshare-0.2.0.dist-info/WHEEL,sha256=UwKY2Z8fbdPcg13t7_TnpAXl2n8eG4G4JQJLC4nARyk,88
4
+ qshare-0.2.0.dist-info/entry_points.txt,sha256=4CVmwudLjYg8tF-MFd_5pl8jfvYjtwaDOtVkT8SkkM8,44
5
+ qshare/,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ qshare/__init__.py,sha256=37TXLwS35XmGk5BxwSjeitivOpazoHmUEs3b61kwSrU,65
7
+ qshare/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
8
+ qshare/_binaries/cloudflared.exe,sha256=KDeIjMD11Y8VttxHg3bekLTTulJBx5R0VdHgoN9ClxI,54976432
9
+ qshare/cli.py,sha256=XNMMybQ961LGsSZk0KOidK4F8JYxHCCKe8W_0XM9DPs,16108
10
+ qshare-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.18
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ qshare = qshare.cli:main
3
+