drp-dev 1.0.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.
cli/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """drp CLI — command-line tool for drp.
2
+
3
+ Drop, share, and manage text snippets and files from the terminal.
4
+ https://drp.fyi
5
+ """
6
+
7
+ # VERSION file holds major.minor (e.g. "1.0") — only bumped manually.
8
+ # CI queries PyPI for latest published version, bumps patch +1,
9
+ # and seds this line to a literal before building: __version__ = '1.0.42'
10
+ # Nothing committed back — merges between dev↔main stay clean.
11
+ # Fallback: importlib.metadata for installed packages.
12
+
13
+ def _resolve_version():
14
+ from pathlib import Path
15
+ vf = Path(__file__).resolve().parent.parent / 'VERSION'
16
+ if vf.is_file():
17
+ return vf.read_text().strip()
18
+ try:
19
+ from importlib.metadata import version
20
+ return version('drp-dev')
21
+ except Exception:
22
+ pass
23
+ try:
24
+ from importlib.metadata import version
25
+ return version('drp')
26
+ except Exception:
27
+ return '0.0.0'
28
+
29
+ __version__ = '1.0.0'
30
+ DEFAULT_HOST = 'https://drp.fyi'
cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from cli.drp import main
2
+
3
+ main()
cli/api/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Public API surface for the drp CLI.
3
+ Import from here to keep command modules clean.
4
+ """
5
+
6
+ from .auth import get_csrf, login
7
+ from .text import upload_text, get_clipboard
8
+ from .file import upload_file, get_file, upload_from_url
9
+ from .actions import delete, rename, renew, list_drops, key_exists, save_bookmark
10
+ from .helpers import slug, err, ok
11
+
12
+ __all__ = [
13
+ 'get_csrf', 'login',
14
+ 'upload_text', 'get_clipboard',
15
+ 'upload_file', 'get_file', 'upload_from_url',
16
+ 'delete', 'rename', 'renew', 'list_drops', 'key_exists', 'save_bookmark',
17
+ 'slug', 'err', 'ok',
18
+ ]
cli/api/actions.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ Drop action API calls: delete, rename, renew, list, key_exists, save_bookmark.
3
+
4
+ URL conventions:
5
+ Clipboard: /key/delete|rename|renew|save/
6
+ File: /f/key/delete|rename|renew|save/
7
+
8
+ Return value conventions for callers (manage.py):
9
+ rename() → new_key string on success
10
+ False on a known/reported error (404, 409, 403 …)
11
+ None on an unexpected error (network, bad JSON, etc.)
12
+ delete() → True / False (unchanged)
13
+ renew() → (expires_at, renewals) / (None, None) (unchanged)
14
+ """
15
+
16
+ from .auth import get_csrf
17
+ from .helpers import err
18
+
19
+
20
+ def _url(host, ns, key, action):
21
+ if ns == 'f':
22
+ return f'{host}/f/{key}/{action}/'
23
+ return f'{host}/{key}/{action}/'
24
+
25
+
26
+ def delete(host, session, key, ns='c'):
27
+ csrf = get_csrf(host, session)
28
+ try:
29
+ res = session.delete(
30
+ _url(host, ns, key, 'delete'),
31
+ headers={'X-CSRFToken': csrf},
32
+ timeout=10,
33
+ )
34
+ if res.ok:
35
+ return True
36
+ if res.status_code == 404:
37
+ err(f'Drop not found. If this is a file drop, use: drp rm -f {key}')
38
+ _report_http('rm', 404, f'delete ns={ns} — likely wrong namespace')
39
+ return False
40
+ _handle_error(res, 'Delete failed')
41
+ _report_http('rm', res.status_code, f'delete ns={ns}')
42
+ except Exception as e:
43
+ err(f'Delete error: {e}')
44
+ return False
45
+
46
+
47
+ def rename(host, session, key, new_key, ns='c'):
48
+ """
49
+ Rename a drop key.
50
+
51
+ Returns:
52
+ str — the new key on success
53
+ False — a known error that has already been printed and reported
54
+ (404 wrong-namespace, 409 key taken, 403 locked, 400 bad input)
55
+ None — an unexpected error (network failure, unhandled status code)
56
+ caller should file a SilentFailure report
57
+ """
58
+ csrf = get_csrf(host, session)
59
+ try:
60
+ res = session.post(
61
+ _url(host, ns, key, 'rename'),
62
+ data={'new_key': new_key, 'csrfmiddlewaretoken': csrf},
63
+ timeout=10,
64
+ )
65
+ if res.ok:
66
+ return res.json().get('key')
67
+
68
+ # ── Known errors — print a helpful message and report, then return
69
+ # False so the caller knows not to file a redundant SilentFailure. ──
70
+ if res.status_code == 404:
71
+ ns_flag = '-f ' if ns == 'f' else ''
72
+ other_flag = '' if ns == 'f' else '-f '
73
+ err(
74
+ f'Drop /{ns_flag}{key}/ not found. '
75
+ f'If this is a {"file" if ns == "c" else "clipboard"} drop, '
76
+ f'use: drp mv {other_flag}{key} {new_key}'
77
+ )
78
+ _report_http('mv', 404, f'rename ns={ns} — likely wrong namespace')
79
+ return False
80
+
81
+ if res.status_code == 409:
82
+ err(f'Key "{new_key}" is already taken.')
83
+ _report_http('mv', 409, f'rename ns={ns} key conflict')
84
+ return False
85
+
86
+ if res.status_code == 403:
87
+ try:
88
+ msg = res.json().get('error', 'Permission denied.')
89
+ except Exception:
90
+ msg = 'Permission denied.'
91
+ err(f'Rename blocked: {msg}')
92
+ _report_http('mv', 403, f'rename ns={ns}')
93
+ return False
94
+
95
+ if res.status_code == 400:
96
+ _handle_error(res, 'Rename failed')
97
+ _report_http('mv', 400, f'rename ns={ns}')
98
+ return False
99
+
100
+ # Unexpected status — let caller decide whether to report
101
+ _handle_error(res, 'Rename failed')
102
+ _report_http('mv', res.status_code, f'rename ns={ns}')
103
+
104
+ except Exception as e:
105
+ err(f'Rename error: {e}')
106
+
107
+ return None
108
+
109
+
110
+ def renew(host, session, key, ns='c'):
111
+ csrf = get_csrf(host, session)
112
+ try:
113
+ res = session.post(
114
+ _url(host, ns, key, 'renew'),
115
+ data={'csrfmiddlewaretoken': csrf},
116
+ timeout=10,
117
+ )
118
+ if res.ok:
119
+ data = res.json()
120
+ return data.get('expires_at'), data.get('renewals')
121
+ _handle_error(res, 'Renew failed')
122
+ _report_http('renew', res.status_code, f'renew ns={ns}')
123
+ except Exception as e:
124
+ err(f'Renew error: {e}')
125
+ return None, None
126
+
127
+
128
+ def save_bookmark(host, session, key, ns='c'):
129
+ """
130
+ Bookmark a drop. Returns True if saved, False on failure.
131
+ Requires login — server returns 403 if not authenticated.
132
+ """
133
+ csrf = get_csrf(host, session)
134
+ try:
135
+ res = session.post(
136
+ _url(host, ns, key, 'save'),
137
+ data={'csrfmiddlewaretoken': csrf},
138
+ timeout=10,
139
+ allow_redirects=False,
140
+ )
141
+ if res.status_code in (301, 302, 303):
142
+ err('drp save requires a logged-in account. Run: drp login')
143
+ return False
144
+ if res.ok:
145
+ return True
146
+ if res.status_code == 403:
147
+ err('drp save requires a logged-in account. Run: drp login')
148
+ return False
149
+ if res.status_code == 404:
150
+ err(f'Drop /{key}/ not found.')
151
+ return False
152
+ _handle_error(res, 'Save failed')
153
+ _report_http('save', res.status_code, f'save_bookmark ns={ns}')
154
+ except Exception as e:
155
+ err(f'Save error: {e}')
156
+ return False
157
+
158
+
159
+ def list_drops(host, session):
160
+ try:
161
+ res = session.get(
162
+ f'{host}/auth/account/',
163
+ headers={'Accept': 'application/json'},
164
+ timeout=15,
165
+ )
166
+ if res.ok:
167
+ return res.json().get('drops', [])
168
+ if res.status_code in (302, 403):
169
+ return None
170
+ err(f'Server returned {res.status_code}.')
171
+ _report_http('ls', res.status_code, 'list_drops')
172
+ except Exception as e:
173
+ err(f'List error: {e}')
174
+ return None
175
+
176
+
177
+ def key_exists(host, session, key, ns='c'):
178
+ try:
179
+ res = session.get(
180
+ f'{host}/check-key/',
181
+ params={'key': key, 'ns': ns},
182
+ timeout=10,
183
+ )
184
+ if res.ok:
185
+ return not res.json().get('available', True)
186
+ except Exception:
187
+ pass
188
+ return False
189
+
190
+
191
+ def _handle_error(res, prefix):
192
+ try:
193
+ msg = res.json().get('error', res.text[:200])
194
+ except Exception:
195
+ msg = res.text[:200]
196
+ err(f'{prefix}: {msg}')
197
+
198
+
199
+ def _report_http(command: str, status_code: int, context: str) -> None:
200
+ """Fire-and-forget — never raises."""
201
+ try:
202
+ from cli.crash_reporter import report_http_error
203
+ report_http_error(command, status_code, context)
204
+ except Exception:
205
+ pass
cli/api/auth.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ Authentication helpers: CSRF token fetching and login.
3
+ """
4
+
5
+ from .helpers import err
6
+
7
+
8
+ def get_csrf(host, session):
9
+ """Return csrftoken, fetching from server only if not already in session."""
10
+ token = _first_csrf(session)
11
+ if token:
12
+ return token
13
+ # Fetch the login page — guaranteed to set the csrftoken cookie
14
+ # (home page may not render {% csrf_token %} and won't set the cookie)
15
+ session.get(f'{host}/auth/login/', timeout=10)
16
+ return _first_csrf(session) or ''
17
+
18
+
19
+ def _first_csrf(session):
20
+ """Safely get csrftoken even if duplicate cookies exist."""
21
+ for cookie in session.cookies:
22
+ if cookie.name == 'csrftoken':
23
+ return cookie.value
24
+ return None
25
+
26
+
27
+ def login(host, session, identifier, password):
28
+ """
29
+ Authenticate with the drp server using username or email.
30
+ Returns True on success, False on bad credentials.
31
+ Raises requests.RequestException on network errors.
32
+ """
33
+ csrf = get_csrf(host, session)
34
+ res = session.post(
35
+ f'{host}/auth/login/',
36
+ data={'email': identifier, 'password': password, 'csrfmiddlewaretoken': csrf},
37
+ timeout=10,
38
+ allow_redirects=False,
39
+ )
40
+ return res.status_code in (301, 302)
cli/api/file.py ADDED
@@ -0,0 +1,340 @@
1
+ """
2
+ File drop API calls.
3
+ """
4
+
5
+ import os
6
+ import mimetypes
7
+
8
+ import requests as _requests
9
+
10
+ from .auth import get_csrf
11
+ from .helpers import err
12
+
13
+ CHUNK = 256 * 1024
14
+
15
+
16
+ def _report(command, msg):
17
+ try:
18
+ from cli.crash_reporter import report
19
+ report(command, RuntimeError(msg))
20
+ except Exception:
21
+ pass
22
+
23
+
24
+ def _touch_session():
25
+ try:
26
+ from cli.session import SESSION_FILE
27
+ SESSION_FILE.touch()
28
+ except Exception:
29
+ pass
30
+
31
+
32
+ # ── Upload ────────────────────────────────────────────────────────────────────
33
+
34
+ def upload_file(host, session, filepath, key=None, expiry_days=None, password=None,
35
+ is_test=False, schedule=None, webhook_url=None, notify=None,
36
+ is_public=False, tags=None):
37
+ """
38
+ Upload a file using the prepare → direct-PUT → confirm flow.
39
+ Returns the drop key string on success, None on failure.
40
+ """
41
+ from cli.progress import ProgressBar
42
+
43
+ size = os.path.getsize(filepath)
44
+ filename = os.path.basename(filepath)
45
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
46
+
47
+ # ── Step 1: prepare ───────────────────────────────────────────────────────
48
+ payload = {
49
+ "filename": filename,
50
+ "size": size,
51
+ "content_type": content_type,
52
+ "ns": "f",
53
+ }
54
+ if key:
55
+ payload["key"] = key
56
+ if expiry_days:
57
+ payload["expiry_days"] = expiry_days
58
+ if is_test:
59
+ payload["is_test"] = True
60
+
61
+ try:
62
+ csrf = get_csrf(host, session)
63
+ res = session.post(
64
+ f"{host}/upload/prepare/",
65
+ json=payload,
66
+ headers={"X-CSRFToken": csrf},
67
+ timeout=30,
68
+ )
69
+ if not res.ok:
70
+ msg = f"Prepare failed (HTTP {res.status_code})"
71
+ _handle_error(res, "Prepare failed")
72
+ _report("up", msg)
73
+ return None
74
+ prep = res.json()
75
+ _touch_session()
76
+ except Exception as e:
77
+ err(f"Prepare error: {e}")
78
+ raise
79
+
80
+ presigned_url = prep["presigned_url"]
81
+ drop_key = prep["key"]
82
+
83
+ # ── Step 2: stream file directly to B2 ───────────────────────────────────
84
+ bar = ProgressBar(size, label="uploading")
85
+
86
+ class _ProgressFile:
87
+ def __init__(self, path):
88
+ self._f = open(path, "rb")
89
+ def read(self, n=-1):
90
+ chunk = self._f.read(n)
91
+ if chunk:
92
+ bar.update(len(chunk))
93
+ return chunk
94
+ def __len__(self):
95
+ return size
96
+ def close(self):
97
+ self._f.close()
98
+
99
+ pf = _ProgressFile(filepath)
100
+ try:
101
+ put_res = _requests.put(
102
+ presigned_url,
103
+ data=pf,
104
+ headers={
105
+ "Content-Type": content_type,
106
+ "Content-Length": str(size),
107
+ },
108
+ timeout=None,
109
+ )
110
+ if not put_res.ok:
111
+ msg = f"B2 upload failed (HTTP {put_res.status_code})"
112
+ err(f"{msg}: {put_res.text[:200]}")
113
+ _report("up", msg)
114
+ return None
115
+ except Exception as e:
116
+ err(f"Upload error: {e}")
117
+ raise
118
+ finally:
119
+ pf.close()
120
+
121
+ bar.done()
122
+
123
+ # ── Step 3: confirm ───────────────────────────────────────────────────────
124
+ confirm_payload = {
125
+ "key": drop_key,
126
+ "ns": "f",
127
+ "filename": filename,
128
+ "content_type": content_type,
129
+ }
130
+ if expiry_days:
131
+ confirm_payload["expiry_days"] = expiry_days
132
+ if password:
133
+ confirm_payload["password"] = password
134
+ if is_test:
135
+ confirm_payload["is_test"] = True
136
+ if schedule:
137
+ confirm_payload["schedule"] = schedule
138
+ if webhook_url:
139
+ confirm_payload["webhook_url"] = webhook_url
140
+ if notify:
141
+ confirm_payload["notify"] = notify
142
+ if is_public:
143
+ confirm_payload["is_public"] = True
144
+ if tags:
145
+ confirm_payload["tags"] = tags
146
+
147
+ try:
148
+ csrf = get_csrf(host, session)
149
+ res = session.post(
150
+ f"{host}/upload/confirm/",
151
+ json=confirm_payload,
152
+ headers={"X-CSRFToken": csrf},
153
+ timeout=30,
154
+ )
155
+ if res.ok:
156
+ _touch_session()
157
+ return res.json().get("key")
158
+ msg = f"Confirm failed (HTTP {res.status_code})"
159
+ _handle_error(res, "Confirm failed")
160
+ _report("up", msg)
161
+ except Exception as e:
162
+ err(f"Confirm error: {e}")
163
+ raise
164
+
165
+ return None
166
+
167
+
168
+ # ── Download ──────────────────────────────────────────────────────────────────
169
+
170
+ def get_file(host, session, key, password=''):
171
+ """
172
+ Fetch a file drop.
173
+
174
+ Returns:
175
+ ('file', (bytes_content, filename)) — success
176
+ ('password_required', None) — password needed / wrong password
177
+ (None, None) — not found, expired, or error
178
+ """
179
+ from cli.progress import ProgressBar
180
+
181
+ headers = {"Accept": "application/json"}
182
+ if password:
183
+ headers["X-Drop-Password"] = password
184
+
185
+ try:
186
+ res = session.get(
187
+ f"{host}/f/{key}/",
188
+ headers=headers,
189
+ timeout=30,
190
+ )
191
+
192
+ if res.status_code == 401:
193
+ return 'password_required', None
194
+
195
+ if not res.ok:
196
+ _handle_http_error(res, key)
197
+ return None, None
198
+
199
+ _touch_session()
200
+
201
+ data = res.json()
202
+ if data.get("kind") != "file":
203
+ err(f"/f/{key}/ is not a file drop.")
204
+ return None, None
205
+
206
+ filename = data.get("filename", key)
207
+ filesize = data.get("filesize", 0)
208
+
209
+ b2_url = data.get("presigned_url")
210
+
211
+ if not b2_url:
212
+ download_path = data.get("download")
213
+ if not download_path:
214
+ err(f"No download URL in response for /f/{key}/.")
215
+ _report("get", "missing both presigned_url and download fields")
216
+ return None, None
217
+
218
+ dl_res = session.get(
219
+ f"{host}{download_path}",
220
+ timeout=10,
221
+ allow_redirects=False,
222
+ )
223
+ if dl_res.status_code == 401:
224
+ return 'password_required', None
225
+ if dl_res.status_code in (301, 302, 303, 307, 308):
226
+ b2_url = dl_res.headers["Location"]
227
+ elif dl_res.ok:
228
+ return "file", (dl_res.content, filename)
229
+ else:
230
+ msg = f"Download redirect failed (HTTP {dl_res.status_code})"
231
+ err(f"{msg}.")
232
+ _report("get", msg)
233
+ return None, None
234
+
235
+ bar = ProgressBar(max(filesize, 1), label="downloading")
236
+ chunks = []
237
+ downloaded = 0
238
+ retries = 3
239
+
240
+ for attempt in range(retries + 1):
241
+ req_headers = {}
242
+ if downloaded:
243
+ req_headers["Range"] = f"bytes={downloaded}-"
244
+ try:
245
+ with _requests.get(b2_url, stream=True, timeout=30,
246
+ headers=req_headers) as stream:
247
+ if stream.status_code not in (200, 206):
248
+ msg = f"B2 download failed (HTTP {stream.status_code})"
249
+ err(f"{msg}.")
250
+ _report("get", msg)
251
+ return None, None
252
+ for chunk in stream.iter_content(chunk_size=CHUNK):
253
+ if chunk:
254
+ chunks.append(chunk)
255
+ downloaded += len(chunk)
256
+ bar.update(len(chunk))
257
+ break # success — exit retry loop
258
+ except (_requests.exceptions.ChunkedEncodingError,
259
+ _requests.exceptions.ConnectionError) as exc:
260
+ if attempt < retries:
261
+ import time as _time
262
+ _time.sleep(1)
263
+ continue
264
+ err(f"Download failed after {retries + 1} attempts: {exc}")
265
+ return None, None
266
+
267
+ bar.done()
268
+ return "file", (b"".join(chunks), filename)
269
+
270
+ except Exception as e:
271
+ err(f"Get error: {e}")
272
+ raise
273
+
274
+
275
+ def _handle_error(res, prefix):
276
+ try:
277
+ msg = res.json().get("error", res.text[:200])
278
+ except Exception:
279
+ msg = res.text[:200]
280
+ err(f"{prefix}: {msg}")
281
+
282
+
283
+ def _handle_http_error(res, key):
284
+ if res.status_code == 404:
285
+ err(f"File /f/{key}/ not found.")
286
+ elif res.status_code == 410:
287
+ err(f"File /f/{key}/ has expired.")
288
+ else:
289
+ msg = f"Server returned {res.status_code}"
290
+ err(f"{msg}.")
291
+ _report("get", msg)
292
+
293
+
294
+ # ── Remote URL upload ─────────────────────────────────────────────────────────
295
+
296
+ def upload_from_url(host, session, url, key=None, expiry_days=None,
297
+ password=None, is_test=False, schedule=None,
298
+ webhook_url=None, notify=None, is_public=False, tags=None):
299
+ """
300
+ Ask the server to fetch a URL and store it as a file drop.
301
+ Returns (key, filename, filesize) on success, None on failure.
302
+ """
303
+ payload = {"url": url}
304
+ if key:
305
+ payload["key"] = key
306
+ if expiry_days:
307
+ payload["expiry_days"] = expiry_days
308
+ if password:
309
+ payload["password"] = password
310
+ if is_test:
311
+ payload["is_test"] = True
312
+ if schedule:
313
+ payload["schedule"] = schedule
314
+ if webhook_url:
315
+ payload["webhook_url"] = webhook_url
316
+ if notify:
317
+ payload["notify"] = notify
318
+ if is_public:
319
+ payload["is_public"] = True
320
+ if tags:
321
+ payload["tags"] = tags
322
+
323
+ try:
324
+ csrf = get_csrf(host, session)
325
+ res = session.post(
326
+ f"{host}/upload/from-url/",
327
+ json=payload,
328
+ headers={"X-CSRFToken": csrf},
329
+ timeout=120, # server-side fetch can take a while
330
+ )
331
+ if not res.ok:
332
+ _handle_error(res, "Remote upload failed")
333
+ _report("up", f"upload_from_url HTTP {res.status_code}")
334
+ return None
335
+ _touch_session()
336
+ data = res.json()
337
+ return data.get("key"), data.get("filename", ""), data.get("filesize", 0)
338
+ except Exception as e:
339
+ err(f"Remote upload error: {e}")
340
+ raise
cli/api/helpers.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Small utilities shared across CLI API modules.
3
+ """
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def slug(name):
10
+ """Turn a filename into a url-safe slug (max 40 chars)."""
11
+ import secrets
12
+ import re
13
+ stem = Path(name).stem
14
+ safe = ''.join(c if c.isalnum() or c in '-_' else '-' for c in stem).strip('-')
15
+ safe = re.sub(r'-{2,}', '-', safe) # collapse consecutive hyphens
16
+ return safe[:40] or secrets.token_urlsafe(6)
17
+
18
+
19
+ def err(msg):
20
+ """Print a formatted error to stderr."""
21
+ from cli.format import red
22
+ print(f' {red("✗", stream=sys.stderr)} {msg}', file=sys.stderr)
23
+
24
+
25
+ def ok(msg):
26
+ """Print a formatted success message."""
27
+ from cli.format import green
28
+ print(f' {green("✓")} {msg}')