drp-cli 0.1.3__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.
drp_cli-0.1.3/CLI.md ADDED
@@ -0,0 +1,57 @@
1
+ # drp
2
+
3
+ Drop text and files from the command line — get a link instantly.
4
+
5
+ ```
6
+ pip install drp-cli
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ drp setup # configure host & log in
13
+ drp up notes.txt # upload a file → prints URL
14
+ drp up "hello world" # upload text → prints URL
15
+ drp up doc.pdf -k cv # upload with a custom key
16
+ drp get mykey # text → stdout, file → saved to disk
17
+ drp get mykey -o a.txt # save file with custom name
18
+ drp rm mykey # delete a drop
19
+ drp mv mykey newkey # rename a drop
20
+ drp renew mykey # renew a drop's expiry (paid)
21
+ drp ls # list your drops (requires login)
22
+ drp status # show config
23
+ drp --version # show version
24
+ ```
25
+
26
+ ## How it works
27
+
28
+ 1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
29
+ 2. `drp up` uploads a file or text string and prints the shareable URL
30
+ 3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
31
+ 4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
32
+
33
+ ## Configuration
34
+
35
+ Config is stored at `~/.config/drp/config.json`:
36
+
37
+ ```json
38
+ {
39
+ "host": "https://drp.vicnas.me",
40
+ "email": "you@example.com"
41
+ }
42
+ ```
43
+
44
+ ## Self-hosted
45
+
46
+ Point the CLI at your own instance:
47
+
48
+ ```bash
49
+ drp setup
50
+ # enter your server URL when prompted
51
+ ```
52
+
53
+ See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
54
+
55
+ ## License
56
+
57
+ MIT
drp_cli-0.1.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victorio Nascimento
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
drp_cli-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: drp-cli
3
+ Version: 0.1.3
4
+ Summary: Drop text and files from the command line — get a link instantly.
5
+ Author: Vic Nas
6
+ License: MIT
7
+ Project-URL: Homepage, https://drp.vicnas.me
8
+ Project-URL: Repository, https://github.com/vicnasdev/drp
9
+ Project-URL: Issues, https://github.com/vicnasdev/drp/issues
10
+ Keywords: cli,file-sharing,pastebin,drops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28
24
+ Dynamic: license-file
25
+
26
+ # drp
27
+
28
+ Drop text and files from the command line — get a link instantly.
29
+
30
+ ```
31
+ pip install drp-cli
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ drp setup # configure host & log in
38
+ drp up notes.txt # upload a file → prints URL
39
+ drp up "hello world" # upload text → prints URL
40
+ drp up doc.pdf -k cv # upload with a custom key
41
+ drp get mykey # text → stdout, file → saved to disk
42
+ drp get mykey -o a.txt # save file with custom name
43
+ drp rm mykey # delete a drop
44
+ drp mv mykey newkey # rename a drop
45
+ drp renew mykey # renew a drop's expiry (paid)
46
+ drp ls # list your drops (requires login)
47
+ drp status # show config
48
+ drp --version # show version
49
+ ```
50
+
51
+ ## How it works
52
+
53
+ 1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
54
+ 2. `drp up` uploads a file or text string and prints the shareable URL
55
+ 3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
56
+ 4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
57
+
58
+ ## Configuration
59
+
60
+ Config is stored at `~/.config/drp/config.json`:
61
+
62
+ ```json
63
+ {
64
+ "host": "https://drp.vicnas.me",
65
+ "email": "you@example.com"
66
+ }
67
+ ```
68
+
69
+ ## Self-hosted
70
+
71
+ Point the CLI at your own instance:
72
+
73
+ ```bash
74
+ drp setup
75
+ # enter your server URL when prompted
76
+ ```
77
+
78
+ See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,134 @@
1
+ # drp
2
+
3
+ Drop files or paste text, get a link instantly. No account, no friction — just a key.
4
+
5
+ - `/your-key` — view, copy, download, or replace a drop
6
+ - Anonymous text drops expire after **24h** (text) or **90 days** (files)
7
+ - Paid accounts get locked drops, longer expiry, and renewable links
8
+
9
+ ## Features
10
+
11
+ - **Text & file drops** — paste anything or drag a file, get a shareable URL
12
+ - **Custom keys** — pick a memorable key or get an auto-generated one
13
+ - **Locking** — paid drops are locked to the owner's account; anon drops have a 24h edit window
14
+ - **Expiry & renewal** — paid accounts set explicit expiry dates and can renew any time
15
+ - **Dashboard** — logged-in users see all drops server-side; anon users get a local browser list with export/import
16
+ - **CLI tool** — `drp up file.txt`, `drp up "some text"`, `drp get mykey`
17
+ - **Self-hostable** — deploys to Railway in a few clicks, runs locally with SQLite
18
+
19
+ ## Deploy on Railway
20
+
21
+ [![Deploy on Railway](https://railway.app/button.svg)](https://railway.com?referralCode=ZIdvo-)
22
+
23
+ 1. Fork this repo
24
+ 2. New project on Railway → Deploy from GitHub repo
25
+ 3. Add a PostgreSQL plugin
26
+ 4. Set environment variables:
27
+
28
+ ```
29
+ SECRET_KEY=random_large_string
30
+ DOMAIN=RAILWAY_PUBLIC_DOMAIN
31
+ DB_URL=Postgres.DATABASE_URL
32
+ CLOUDINARY_CLOUD_NAME=
33
+ CLOUDINARY_API_KEY=
34
+ CLOUDINARY_API_SECRET=
35
+ EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
36
+ EMAIL_HOST_USER=you@gmail.com
37
+ EMAIL_HOST_PASSWORD=xxxx xxxx xxxx xxxx
38
+ DEFAULT_FROM_EMAIL=you@gmail.com
39
+ ADMIN_EMAIL=you@gmail.com
40
+ LEMONSQUEEZY_API_KEY=
41
+ LEMONSQUEEZY_SIGNING_SECRET=
42
+ LEMONSQUEEZY_STORE_ID=
43
+ LEMONSQUEEZY_STARTER_VARIANT_ID=
44
+ LEMONSQUEEZY_PRO_VARIANT_ID=
45
+ ```
46
+
47
+ 5. Start command:
48
+
49
+ ```
50
+ python manage.py createcachetable && python manage.py collectstatic --noinput && python manage.py migrate && gunicorn project.wsgi --bind 0.0.0.0:$PORT
51
+ ```
52
+
53
+ > Note: run `python manage.py makemigrations` locally the first time before deploying.
54
+
55
+ ## Run locally
56
+
57
+ ```bash
58
+ pip install -r requirements.txt
59
+ cp .env.example .env # fill in Cloudinary values; leave EMAIL_* blank for console output
60
+ python manage.py migrate
61
+ python manage.py createcachetable
62
+ python manage.py runserver # or: make dev
63
+ ```
64
+
65
+ Password reset emails print to the terminal in dev — no mail server needed.
66
+
67
+ ## Makefile
68
+
69
+ ```
70
+ make dev # start Django dev server
71
+ make test # run all tests (core + cli)
72
+ make migrate # run migrations
73
+ make install # pip install -e . (installs drp command)
74
+ ```
75
+
76
+ ## Gmail App Password (production email)
77
+
78
+ Self-service password reset requires an outbound mail account. Gmail works fine:
79
+
80
+ 1. Enable **2-Step Verification** on your Google account
81
+ 2. Go to **Security → App passwords**, create one named `drp`
82
+ 3. Copy the 16-character password into `EMAIL_HOST_PASSWORD` in your `.env`
83
+
84
+ 500 emails/day free. For higher volume use [Brevo](https://brevo.com) (3,000/mo free).
85
+
86
+ ## CLI
87
+
88
+ Install from PyPI:
89
+
90
+ ```bash
91
+ pip install drp-cli
92
+ ```
93
+
94
+ Or from source:
95
+
96
+ ```bash
97
+ pip install . # or: make install
98
+ ```
99
+
100
+ Usage:
101
+
102
+ ```bash
103
+ drp setup # configure host & log in
104
+ drp up notes.txt # upload a file → prints URL
105
+ drp up "hello world" # upload text → prints URL
106
+ drp up doc.pdf -k cv # upload with custom key
107
+ drp get mykey # text → stdout, file → saved to disk
108
+ drp get mykey -o a.txt # save file with custom name
109
+ drp rm mykey # delete a drop
110
+ drp mv mykey newkey # rename a drop
111
+ drp renew mykey # renew expiry (paid)
112
+ drp ls # list your drops (requires login)
113
+ drp status # show config
114
+ drp --version # show version
115
+ ```
116
+
117
+ Works anonymously or logged in. Logged-in users get locked drops, longer expiry, and `drp ls`.
118
+
119
+ ## Plans
120
+
121
+ | | Free | Starter ($3/mo) | Pro ($8/mo) |
122
+ |---|---|---|---|
123
+ | Max file size | 200 MB | 1 GB | 5 GB |
124
+ | Max text size | 500 KB | 2 MB | 10 MB |
125
+ | Storage | — | 5 GB | 20 GB |
126
+ | Expiry | 24h / 90d | Up to 1 year | Up to 3 years |
127
+ | Locked drops | ✗ | ✓ | ✓ |
128
+ | Renewable | ✗ | ✓ | ✓ |
129
+
130
+ Upgrade from your account page after signing up — billing is handled via Lemon Squeezy.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,3 @@
1
+ """drp CLI — command-line tool for drp."""
2
+
3
+ __version__ = '0.1.3'
@@ -0,0 +1,206 @@
1
+ """
2
+ HTTP helpers for the drp CLI.
3
+
4
+ All server communication lives here: CSRF, login, upload, download, delete,
5
+ and key-existence checks.
6
+ """
7
+
8
+ import os
9
+ import secrets
10
+ import requests
11
+ from pathlib import Path
12
+
13
+
14
+ def get_csrf(host, session):
15
+ """Hit the home page to pick up the csrftoken cookie."""
16
+ session.get(f'{host}/', timeout=10)
17
+ return session.cookies.get('csrftoken', '')
18
+
19
+
20
+ def login(host, session, email, password):
21
+ """Authenticate with the drp server. Returns True on success."""
22
+ csrf = get_csrf(host, session)
23
+ res = session.post(
24
+ f'{host}/auth/login/',
25
+ data={'email': email, 'password': password, 'csrfmiddlewaretoken': csrf},
26
+ timeout=10,
27
+ allow_redirects=False,
28
+ )
29
+ return res.status_code in (302, 301)
30
+
31
+
32
+ def upload_text(host, session, text, key=None):
33
+ """Upload text content. Returns the key on success, None on failure."""
34
+ csrf = get_csrf(host, session)
35
+ data = {'content': text, 'csrfmiddlewaretoken': csrf}
36
+ if key:
37
+ data['key'] = key
38
+ try:
39
+ res = session.post(f'{host}/save/', data=data, timeout=30)
40
+ if res.ok:
41
+ return res.json().get('key')
42
+ else:
43
+ _err(f'upload failed: {res.text[:200]}')
44
+ except Exception as e:
45
+ _err(f'upload error: {e}')
46
+ return None
47
+
48
+
49
+ def upload_file(host, session, filepath, key=None):
50
+ """Upload a file. Returns the key on success, None on failure."""
51
+ csrf = get_csrf(host, session)
52
+ data = {'csrfmiddlewaretoken': csrf}
53
+ if key:
54
+ data['key'] = key
55
+ try:
56
+ with open(filepath, 'rb') as f:
57
+ res = session.post(
58
+ f'{host}/save/',
59
+ files={'file': (os.path.basename(filepath), f)},
60
+ data=data,
61
+ timeout=120,
62
+ )
63
+ if res.ok:
64
+ return res.json().get('key')
65
+ else:
66
+ _err(f'upload failed: {res.text[:200]}')
67
+ except Exception as e:
68
+ _err(f'upload error: {e}')
69
+ return None
70
+
71
+
72
+ def get_drop(host, session, key):
73
+ """
74
+ Fetch a drop's content via the JSON API.
75
+ Returns (kind, content) where kind is 'text' or 'file'.
76
+ For text drops, content is the string.
77
+ For file drops, content is (bytes, filename).
78
+ Returns (None, None) on failure.
79
+ """
80
+ try:
81
+ res = session.get(
82
+ f'{host}/{key}/',
83
+ headers={'Accept': 'application/json'},
84
+ timeout=30,
85
+ )
86
+ if not res.ok:
87
+ if res.status_code == 410:
88
+ _err('drop has expired')
89
+ elif res.status_code == 404:
90
+ _err('drop not found')
91
+ else:
92
+ _err(f'server returned {res.status_code}')
93
+ return None, None
94
+
95
+ data = res.json()
96
+ if data.get('kind') == 'text':
97
+ return 'text', data.get('content', '')
98
+
99
+ # File drop — follow the download redirect
100
+ dl = session.get(
101
+ f'{host}{data["download"]}',
102
+ timeout=120,
103
+ allow_redirects=True,
104
+ )
105
+ if dl.ok:
106
+ filename = data.get('filename', key)
107
+ return 'file', (dl.content, filename)
108
+
109
+ _err('failed to download file')
110
+ return None, None
111
+ except Exception as e:
112
+ _err(f'get error: {e}')
113
+ return None, None
114
+
115
+
116
+ def delete(host, session, key):
117
+ """Delete a drop. Returns True on success."""
118
+ csrf = get_csrf(host, session)
119
+ try:
120
+ res = session.delete(
121
+ f'{host}/{key}/delete/',
122
+ headers={'X-CSRFToken': csrf},
123
+ timeout=10,
124
+ )
125
+ return res.ok
126
+ except Exception:
127
+ return False
128
+
129
+
130
+ def rename(host, session, key, new_key):
131
+ """Rename a drop's key. Returns the new key on success, None on failure."""
132
+ csrf = get_csrf(host, session)
133
+ try:
134
+ res = session.post(
135
+ f'{host}/{key}/rename/',
136
+ data={'new_key': new_key, 'csrfmiddlewaretoken': csrf},
137
+ timeout=10,
138
+ )
139
+ if res.ok:
140
+ return res.json().get('key')
141
+ _err(res.json().get('error', 'rename failed'))
142
+ except Exception as e:
143
+ _err(f'rename error: {e}')
144
+ return None
145
+
146
+
147
+ def renew(host, session, key):
148
+ """Renew a drop's expiry. Returns (expires_at, renewals) on success."""
149
+ csrf = get_csrf(host, session)
150
+ try:
151
+ res = session.post(
152
+ f'{host}/{key}/renew/',
153
+ data={'csrfmiddlewaretoken': csrf},
154
+ timeout=10,
155
+ )
156
+ if res.ok:
157
+ data = res.json()
158
+ return data.get('expires_at'), data.get('renewals')
159
+ _err(res.json().get('error', 'renew failed'))
160
+ except Exception as e:
161
+ _err(f'renew error: {e}')
162
+ return None, None
163
+
164
+
165
+ def list_drops(host, session):
166
+ """List the logged-in user's drops. Returns list of dicts or None."""
167
+ try:
168
+ res = session.get(
169
+ f'{host}/auth/account/',
170
+ headers={'Accept': 'application/json'},
171
+ timeout=15,
172
+ )
173
+ if res.ok:
174
+ return res.json().get('drops', [])
175
+ if res.status_code == 302:
176
+ _err('not logged in')
177
+ else:
178
+ _err(f'server returned {res.status_code}')
179
+ except Exception as e:
180
+ _err(f'list error: {e}')
181
+ return None
182
+
183
+
184
+ def key_exists(host, session, key):
185
+ """Return True if the key exists on the server."""
186
+ try:
187
+ res = session.get(f'{host}/check-key/', params={'key': key}, timeout=10)
188
+ if res.ok:
189
+ return not res.json().get('available', True)
190
+ except Exception:
191
+ pass
192
+ return False
193
+
194
+
195
+ def slug(name):
196
+ """Turn a filename into a url-safe slug (max 40 chars)."""
197
+ stem = Path(name).stem
198
+ safe = ''.join(c if c.isalnum() or c in '-_' else '-' for c in stem).strip('-')
199
+ return safe[:40] or secrets.token_urlsafe(6)
200
+
201
+
202
+ def _err(msg):
203
+ """Print an error message."""
204
+ import sys
205
+ print(f' ✗ {msg}', file=sys.stderr)
206
+
@@ -0,0 +1,26 @@
1
+ """
2
+ Config management for the drp CLI.
3
+
4
+ Stores host and email in ~/.config/drp/config.json.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ CONFIG_DIR = Path.home() / '.config' / 'drp'
11
+ CONFIG_FILE = CONFIG_DIR / 'config.json'
12
+
13
+
14
+ def load(path=None):
15
+ """Load config from disk. Returns empty dict if missing."""
16
+ p = Path(path) if path else CONFIG_FILE
17
+ if p.exists():
18
+ return json.loads(p.read_text())
19
+ return {}
20
+
21
+
22
+ def save(cfg, path=None):
23
+ """Save config to disk, creating parent dirs if needed."""
24
+ p = Path(path) if path else CONFIG_FILE
25
+ p.parent.mkdir(parents=True, exist_ok=True)
26
+ p.write_text(json.dumps(cfg, indent=2) + '\n')
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ drp — command-line tool for dropping text and files.
4
+
5
+ Usage:
6
+ drp setup # configure host & login
7
+ drp login # (re)authenticate
8
+ drp up <file> # upload a file
9
+ drp up <file> --key myname # upload with a custom key
10
+ drp up "some text" # upload text
11
+ drp get <key> # download a drop
12
+ drp rm <key> # delete a drop
13
+ drp mv <key> <new-key> # rename a drop
14
+ drp renew <key> # renew a drop's expiry
15
+ drp ls # list your drops (requires login)
16
+ drp status # show config
17
+ """
18
+
19
+ import sys
20
+ import os
21
+ import getpass
22
+ import argparse
23
+ import requests
24
+
25
+ from cli import config, api
26
+
27
+
28
+ def cmd_setup(args):
29
+ """Interactive first-time setup."""
30
+ cfg = config.load()
31
+ print('drp setup')
32
+ print('─────────')
33
+ default = cfg.get('host', 'https://drp.vicnas.me')
34
+ cfg['host'] = input(f' Host [{default}]: ').strip() or default
35
+ config.save(cfg)
36
+
37
+ answer = input(' Log in now? (y/n) [y]: ').strip().lower()
38
+ if answer != 'n':
39
+ cmd_login(args)
40
+ else:
41
+ config.save(cfg)
42
+
43
+ print(f'\n ✓ Saved to {config.CONFIG_FILE}')
44
+
45
+
46
+ def cmd_login(args):
47
+ """Log in to drp."""
48
+ cfg = config.load()
49
+ host = cfg.get('host', 'https://drp.vicnas.me')
50
+ email = input(' Email: ').strip()
51
+ password = getpass.getpass(' Password: ')
52
+ session = requests.Session()
53
+ if api.login(host, session, email, password):
54
+ cfg['email'] = email
55
+ config.save(cfg)
56
+ print(f' ✓ Logged in as {email}')
57
+ else:
58
+ print(' ✗ Login failed.')
59
+ sys.exit(1)
60
+
61
+
62
+ def cmd_up(args):
63
+ """Upload a file or text."""
64
+ cfg = config.load()
65
+ host = cfg.get('host')
66
+ if not host:
67
+ print('Not configured. Run: drp setup')
68
+ sys.exit(1)
69
+
70
+ session = requests.Session()
71
+ _auto_login(cfg, host, session)
72
+
73
+ target = args.target
74
+ key = args.key
75
+
76
+ # If target is a file path that exists, upload as file
77
+ if os.path.isfile(target):
78
+ if not key:
79
+ key = api.slug(os.path.basename(target))
80
+ result_key = api.upload_file(host, session, target, key=key)
81
+ if result_key:
82
+ url = f'{host}/{result_key}/'
83
+ print(f'{url}')
84
+ else:
85
+ sys.exit(1)
86
+ else:
87
+ # Treat as text content
88
+ result_key = api.upload_text(host, session, target, key=key)
89
+ if result_key:
90
+ url = f'{host}/{result_key}/'
91
+ print(f'{url}')
92
+ else:
93
+ sys.exit(1)
94
+
95
+
96
+ def cmd_get(args):
97
+ """Download a drop."""
98
+ cfg = config.load()
99
+ host = cfg.get('host')
100
+ if not host:
101
+ print('Not configured. Run: drp setup')
102
+ sys.exit(1)
103
+
104
+ session = requests.Session()
105
+ _auto_login(cfg, host, session)
106
+
107
+ key = args.key
108
+ kind, content = api.get_drop(host, session, key)
109
+
110
+ if kind == 'text':
111
+ print(content)
112
+ elif kind == 'file':
113
+ data, filename = content
114
+ out = args.output or filename
115
+ with open(out, 'wb') as f:
116
+ f.write(data)
117
+ print(f' ✓ {out} ({len(data)} bytes)')
118
+ else:
119
+ print(f' ✗ drop not found: {key}')
120
+ sys.exit(1)
121
+
122
+
123
+ def cmd_rm(args):
124
+ """Delete a drop."""
125
+ cfg = config.load()
126
+ host = cfg.get('host')
127
+ if not host:
128
+ print('Not configured. Run: drp setup')
129
+ sys.exit(1)
130
+
131
+ session = requests.Session()
132
+ _auto_login(cfg, host, session)
133
+
134
+ if api.delete(host, session, args.key):
135
+ print(f' ✓ deleted /{args.key}/')
136
+ else:
137
+ print(f' ✗ could not delete /{args.key}/')
138
+ sys.exit(1)
139
+
140
+
141
+ def cmd_mv(args):
142
+ """Rename a drop's key."""
143
+ cfg = config.load()
144
+ host = cfg.get('host')
145
+ if not host:
146
+ print('Not configured. Run: drp setup')
147
+ sys.exit(1)
148
+
149
+ session = requests.Session()
150
+ _auto_login(cfg, host, session)
151
+
152
+ new_key = api.rename(host, session, args.key, args.new_key)
153
+ if new_key:
154
+ print(f' ✓ /{args.key}/ → /{new_key}/')
155
+ else:
156
+ print(f' ✗ could not rename /{args.key}/')
157
+ sys.exit(1)
158
+
159
+
160
+ def cmd_renew(args):
161
+ """Renew a drop's expiry."""
162
+ cfg = config.load()
163
+ host = cfg.get('host')
164
+ if not host:
165
+ print('Not configured. Run: drp setup')
166
+ sys.exit(1)
167
+
168
+ session = requests.Session()
169
+ _auto_login(cfg, host, session)
170
+
171
+ expires_at, renewals = api.renew(host, session, args.key)
172
+ if expires_at:
173
+ print(f' ✓ /{args.key}/ renewed (expires {expires_at}, #{renewals})')
174
+ else:
175
+ print(f' ✗ could not renew /{args.key}/')
176
+ sys.exit(1)
177
+
178
+
179
+ def cmd_ls(args):
180
+ """List your drops."""
181
+ cfg = config.load()
182
+ host = cfg.get('host')
183
+ if not host:
184
+ print('Not configured. Run: drp setup')
185
+ sys.exit(1)
186
+
187
+ session = requests.Session()
188
+ _auto_login(cfg, host, session)
189
+
190
+ drops = api.list_drops(host, session)
191
+ if drops is None:
192
+ print(' ✗ could not list drops (are you logged in?)')
193
+ sys.exit(1)
194
+
195
+ if not drops:
196
+ print(' (no drops)')
197
+ return
198
+
199
+ for d in drops:
200
+ kind = d['kind']
201
+ key = d['key']
202
+ name = d.get('filename') or ''
203
+ if kind == 'file' and name:
204
+ print(f' {key:30s} {kind:4s} {name}')
205
+ else:
206
+ print(f' {key:30s} {kind:4s}')
207
+
208
+
209
+ def cmd_status(args):
210
+ """Show current config."""
211
+ cfg = config.load()
212
+ print('drp status')
213
+ print('──────────')
214
+ print(f' Host: {cfg.get("host", "(not set)")}')
215
+ print(f' Account: {cfg.get("email", "anonymous")}')
216
+ print(f' Config: {config.CONFIG_FILE}')
217
+
218
+
219
+ def _auto_login(cfg, host, session):
220
+ """Silently log in if email is saved. Continues anonymously on failure."""
221
+ email = cfg.get('email')
222
+ if email:
223
+ password = getpass.getpass(f' Password for {email}: ')
224
+ if not api.login(host, session, email, password):
225
+ print(' ⚠ Login failed, continuing as anonymous.')
226
+
227
+
228
+ def main():
229
+ from cli import __version__
230
+
231
+ parser = argparse.ArgumentParser(
232
+ prog='drp',
233
+ description='Drop text and files from the command line.',
234
+ )
235
+ parser.add_argument('--version', '-V', action='version', version=f'%(prog)s {__version__}')
236
+ sub = parser.add_subparsers(dest='command')
237
+
238
+ # setup
239
+ sub.add_parser('setup', help='Configure host & login')
240
+
241
+ # login
242
+ sub.add_parser('login', help='Log in to drp')
243
+
244
+ # up
245
+ p_up = sub.add_parser('up', help='Upload a file or text')
246
+ p_up.add_argument('target', help='File path or text string to upload')
247
+ p_up.add_argument('--key', '-k', default=None, help='Custom key (default: auto from filename)')
248
+
249
+ # get
250
+ p_get = sub.add_parser('get', help='Download a drop')
251
+ p_get.add_argument('key', help='Drop key to download')
252
+ p_get.add_argument('--output', '-o', default=None, help='Output filename (files only)')
253
+
254
+ # rm
255
+ p_rm = sub.add_parser('rm', help='Delete a drop')
256
+ p_rm.add_argument('key', help='Drop key to delete')
257
+
258
+ # mv (rename)
259
+ p_mv = sub.add_parser('mv', help='Rename a drop key')
260
+ p_mv.add_argument('key', help='Current drop key')
261
+ p_mv.add_argument('new_key', help='New key')
262
+
263
+ # renew
264
+ p_renew = sub.add_parser('renew', help='Renew a drop expiry')
265
+ p_renew.add_argument('key', help='Drop key to renew')
266
+
267
+ # ls
268
+ sub.add_parser('ls', help='List your drops (requires login)')
269
+
270
+ # status
271
+ sub.add_parser('status', help='Show config')
272
+
273
+ args = parser.parse_args()
274
+
275
+ commands = {
276
+ 'setup': cmd_setup,
277
+ 'login': cmd_login,
278
+ 'up': cmd_up,
279
+ 'get': cmd_get,
280
+ 'rm': cmd_rm,
281
+ 'mv': cmd_mv,
282
+ 'renew': cmd_renew,
283
+ 'ls': cmd_ls,
284
+ 'status': cmd_status,
285
+ }
286
+
287
+ if args.command in commands:
288
+ commands[args.command](args)
289
+ else:
290
+ parser.print_help()
291
+
292
+
293
+ if __name__ == '__main__':
294
+ main()
@@ -0,0 +1,334 @@
1
+ """
2
+ Tests for the drp CLI helpers (config, api, slug).
3
+
4
+ Uses LiveServerTestCase so api functions talk to a real Django instance.
5
+ All tests are self-cleaning (transaction rollback per test).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+ from unittest.mock import patch
13
+
14
+ from django.contrib.auth.models import User
15
+ from django.test import LiveServerTestCase, override_settings
16
+
17
+ import requests
18
+
19
+ from cli import api, config
20
+
21
+ # Use plain static storage in tests (no collectstatic needed)
22
+ _STATIC_OVERRIDE = override_settings(
23
+ STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage'
24
+ )
25
+
26
+
27
+ # ═══════════════════════════════════════════════════════════════════════════════
28
+ # Config
29
+ # ═══════════════════════════════════════════════════════════════════════════════
30
+
31
+ class ConfigTests(LiveServerTestCase):
32
+ """config.load / config.save with temp files."""
33
+
34
+ def test_load_missing_returns_empty(self):
35
+ """Loading a non-existent config returns {}."""
36
+ self.assertEqual(config.load('/tmp/drp_nope_12345.json'), {})
37
+
38
+ def test_save_and_load_roundtrip(self):
39
+ """save → load returns the same data."""
40
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
41
+ path = f.name
42
+ try:
43
+ cfg = {'host': 'https://example.com', 'email': 'a@b.com'}
44
+ config.save(cfg, path)
45
+ loaded = config.load(path)
46
+ self.assertEqual(loaded, cfg)
47
+ finally:
48
+ os.unlink(path)
49
+
50
+ def test_save_creates_parent_dirs(self):
51
+ """save creates missing parent directories."""
52
+ with tempfile.TemporaryDirectory() as d:
53
+ path = os.path.join(d, 'sub', 'dir', 'config.json')
54
+ config.save({'host': 'x'}, path)
55
+ self.assertTrue(os.path.exists(path))
56
+ self.assertEqual(config.load(path), {'host': 'x'})
57
+
58
+ def test_save_overwrites(self):
59
+ """Saving twice overwrites the first value."""
60
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
61
+ path = f.name
62
+ try:
63
+ config.save({'host': 'old'}, path)
64
+ config.save({'host': 'new'}, path)
65
+ self.assertEqual(config.load(path)['host'], 'new')
66
+ finally:
67
+ os.unlink(path)
68
+
69
+
70
+ # ═══════════════════════════════════════════════════════════════════════════════
71
+ # Slug
72
+ # ═══════════════════════════════════════════════════════════════════════════════
73
+
74
+ class SlugTests(LiveServerTestCase):
75
+ """api.slug turns filenames into url-safe keys."""
76
+
77
+ def test_simple(self):
78
+ self.assertEqual(api.slug('notes.txt'), 'notes')
79
+
80
+ def test_spaces_become_dashes(self):
81
+ self.assertEqual(api.slug('my cool file.pdf'), 'my-cool-file')
82
+
83
+ def test_special_chars_stripped(self):
84
+ self.assertEqual(api.slug('hello@world!.py'), 'hello-world')
85
+
86
+ def test_long_name_truncated(self):
87
+ name = 'a' * 100 + '.txt'
88
+ self.assertLessEqual(len(api.slug(name)), 40)
89
+
90
+ def test_empty_name_gets_random(self):
91
+ """An empty stem (e.g. '.bashrc') still produces a non-empty slug."""
92
+ result = api.slug('.bashrc')
93
+ # .bashrc stem is '.bashrc' → 'bashrc' after stripping
94
+ self.assertTrue(len(result) > 0)
95
+
96
+
97
+ # ═══════════════════════════════════════════════════════════════════════════════
98
+ # API – CSRF & Login
99
+ # ═══════════════════════════════════════════════════════════════════════════════
100
+
101
+ @_STATIC_OVERRIDE
102
+ class AuthApiTests(LiveServerTestCase):
103
+ """CSRF, login, and session handling."""
104
+
105
+ def setUp(self):
106
+ self.user = User.objects.create_user(
107
+ username='cli@test.com', email='cli@test.com', password='pass1234'
108
+ )
109
+ self.session = requests.Session()
110
+
111
+ def test_get_csrf_returns_token(self):
112
+ token = api.get_csrf(self.live_server_url, self.session)
113
+ self.assertTrue(len(token) > 0)
114
+
115
+ def test_login_success(self):
116
+ ok = api.login(self.live_server_url, self.session, 'cli@test.com', 'pass1234')
117
+ self.assertTrue(ok)
118
+
119
+ def test_login_wrong_password(self):
120
+ ok = api.login(self.live_server_url, self.session, 'cli@test.com', 'wrong')
121
+ self.assertFalse(ok)
122
+
123
+ def test_login_nonexistent_user(self):
124
+ ok = api.login(self.live_server_url, self.session, 'no@one.com', 'pass')
125
+ self.assertFalse(ok)
126
+
127
+
128
+ # ═══════════════════════════════════════════════════════════════════════════════
129
+ # API – Upload, Get, Delete (anonymous)
130
+ # ═══════════════════════════════════════════════════════════════════════════════
131
+
132
+ @_STATIC_OVERRIDE
133
+ class AnonDropApiTests(LiveServerTestCase):
134
+ """Upload / get / delete drops as an anonymous user."""
135
+
136
+ def setUp(self):
137
+ self.session = requests.Session()
138
+ self.host = self.live_server_url
139
+
140
+ # ── Text ──────────────────────────────────────────────────────────────
141
+
142
+ def test_upload_text(self):
143
+ key = api.upload_text(self.host, self.session, 'hello world')
144
+ self.assertIsNotNone(key)
145
+ self.assertTrue(len(key) > 0)
146
+
147
+ def test_upload_text_custom_key(self):
148
+ key = api.upload_text(self.host, self.session, 'custom!', key='mykey')
149
+ self.assertEqual(key, 'mykey')
150
+
151
+ def test_get_text_drop(self):
152
+ key = api.upload_text(self.host, self.session, 'i can be retrieved')
153
+ kind, content = api.get_drop(self.host, self.session, key)
154
+ self.assertEqual(kind, 'text')
155
+ self.assertEqual(content, 'i can be retrieved')
156
+
157
+ def test_delete_text_drop(self):
158
+ key = api.upload_text(self.host, self.session, 'delete me')
159
+ ok = api.delete(self.host, self.session, key)
160
+ self.assertTrue(ok)
161
+ # should be gone
162
+ self.assertFalse(api.key_exists(self.host, self.session, key))
163
+
164
+ # ── File ──────────────────────────────────────────────────────────────
165
+
166
+ def test_upload_file(self):
167
+ with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
168
+ f.write(b'file content here')
169
+ f.flush()
170
+ path = f.name
171
+ try:
172
+ key = api.upload_file(self.host, self.session, path, key='filetest')
173
+ self.assertEqual(key, 'filetest')
174
+ finally:
175
+ os.unlink(path)
176
+
177
+ def test_get_file_drop(self):
178
+ with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
179
+ f.write(b'binary data')
180
+ f.flush()
181
+ path = f.name
182
+ try:
183
+ key = api.upload_file(self.host, self.session, path, key='getfile')
184
+ kind, content = api.get_drop(self.host, self.session, key)
185
+ self.assertEqual(kind, 'file')
186
+ data, filename = content
187
+ self.assertIn(b'binary data', data)
188
+ finally:
189
+ os.unlink(path)
190
+
191
+ # ── Key checks ────────────────────────────────────────────────────────
192
+
193
+ def test_key_exists_true(self):
194
+ api.upload_text(self.host, self.session, 'exists', key='taken')
195
+ self.assertTrue(api.key_exists(self.host, self.session, 'taken'))
196
+
197
+ def test_key_exists_false(self):
198
+ self.assertFalse(api.key_exists(self.host, self.session, 'nope-not-here'))
199
+
200
+ # ── Not found ─────────────────────────────────────────────────────────
201
+
202
+ def test_get_nonexistent_drop(self):
203
+ kind, content = api.get_drop(self.host, self.session, 'no-such-key')
204
+ self.assertIsNone(kind)
205
+ self.assertIsNone(content)
206
+
207
+ def test_delete_nonexistent_drop(self):
208
+ ok = api.delete(self.host, self.session, 'no-such-key')
209
+ self.assertFalse(ok)
210
+
211
+
212
+ # ═══════════════════════════════════════════════════════════════════════════════
213
+ # API – Authenticated drops
214
+ # ═══════════════════════════════════════════════════════════════════════════════
215
+
216
+ @_STATIC_OVERRIDE
217
+ class AuthDropApiTests(LiveServerTestCase):
218
+ """Upload / get / delete drops as a logged-in user."""
219
+
220
+ def setUp(self):
221
+ self.user = User.objects.create_user(
222
+ username='auth@test.com', email='auth@test.com', password='pass1234'
223
+ )
224
+ self.session = requests.Session()
225
+ self.host = self.live_server_url
226
+ api.login(self.host, self.session, 'auth@test.com', 'pass1234')
227
+
228
+ def test_upload_text_authed(self):
229
+ key = api.upload_text(self.host, self.session, 'logged in text', key='authtext')
230
+ self.assertEqual(key, 'authtext')
231
+
232
+ def test_upload_file_authed(self):
233
+ with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
234
+ f.write(b'auth file')
235
+ f.flush()
236
+ path = f.name
237
+ try:
238
+ key = api.upload_file(self.host, self.session, path, key='authfile')
239
+ self.assertEqual(key, 'authfile')
240
+ finally:
241
+ os.unlink(path)
242
+
243
+ def test_get_text_authed(self):
244
+ api.upload_text(self.host, self.session, 'authed content', key='authget')
245
+ kind, content = api.get_drop(self.host, self.session, 'authget')
246
+ self.assertEqual(kind, 'text')
247
+ self.assertEqual(content, 'authed content')
248
+
249
+ def test_delete_authed(self):
250
+ api.upload_text(self.host, self.session, 'bye', key='authdel')
251
+ ok = api.delete(self.host, self.session, 'authdel')
252
+ self.assertTrue(ok)
253
+ self.assertFalse(api.key_exists(self.host, self.session, 'authdel'))
254
+
255
+ def test_locked_drop_not_deletable_by_anon(self):
256
+ """A locked drop can't be deleted from a fresh (anonymous) session."""
257
+ from core.models import Drop
258
+ api.upload_text(self.host, self.session, 'locked', key='locktest')
259
+ Drop.objects.filter(key='locktest').update(locked=True)
260
+ anon = requests.Session()
261
+ ok = api.delete(self.host, anon, 'locktest')
262
+ self.assertFalse(ok)
263
+
264
+ def test_rename_drop(self):
265
+ api.upload_text(self.host, self.session, 'rename me', key='oldname')
266
+ new_key = api.rename(self.host, self.session, 'oldname', 'newname')
267
+ self.assertEqual(new_key, 'newname')
268
+ # old key gone, new key exists
269
+ self.assertFalse(api.key_exists(self.host, self.session, 'oldname'))
270
+ self.assertTrue(api.key_exists(self.host, self.session, 'newname'))
271
+
272
+ def test_rename_to_taken_key_fails(self):
273
+ api.upload_text(self.host, self.session, 'first', key='existing')
274
+ api.upload_text(self.host, self.session, 'second', key='movethis')
275
+ result = api.rename(self.host, self.session, 'movethis', 'existing')
276
+ self.assertIsNone(result)
277
+
278
+ def test_renew_drop(self):
279
+ """Paid drops can be renewed."""
280
+ from core.models import Drop, Plan
281
+ from django.utils import timezone
282
+ from datetime import timedelta
283
+ self.user.profile.plan = Plan.STARTER
284
+ self.user.profile.save()
285
+ api.upload_text(self.host, self.session, 'renew me', key='renewtest')
286
+ # Manually set expires_at to something in the near future
287
+ drop = Drop.objects.get(key='renewtest')
288
+ drop.expires_at = timezone.now() + timedelta(days=1)
289
+ drop.save()
290
+ expires_at, renewals = api.renew(self.host, self.session, 'renewtest')
291
+ self.assertIsNotNone(expires_at)
292
+ self.assertEqual(renewals, 1)
293
+
294
+ def test_renew_anon_drop_fails(self):
295
+ """Anon drops (no expires_at) cannot be renewed."""
296
+ anon = requests.Session()
297
+ api.upload_text(self.host, anon, 'no renew', key='anonrenew')
298
+ expires_at, _ = api.renew(self.host, self.session, 'anonrenew')
299
+ self.assertIsNone(expires_at)
300
+
301
+ def test_list_drops(self):
302
+ api.upload_text(self.host, self.session, 'list item 1', key='list1')
303
+ api.upload_text(self.host, self.session, 'list item 2', key='list2')
304
+ drops = api.list_drops(self.host, self.session)
305
+ self.assertIsNotNone(drops)
306
+ keys = [d['key'] for d in drops]
307
+ self.assertIn('list1', keys)
308
+ self.assertIn('list2', keys)
309
+
310
+ def test_list_drops_anon_fails(self):
311
+ """Anonymous session can't list drops (requires login)."""
312
+ anon = requests.Session()
313
+ drops = api.list_drops(self.host, anon)
314
+ self.assertIsNone(drops)
315
+
316
+ def test_list_drops_empty(self):
317
+ drops = api.list_drops(self.host, self.session)
318
+ self.assertIsNotNone(drops)
319
+ self.assertEqual(len(drops), 0)
320
+
321
+
322
+ # ═══════════════════════════════════════════════════════════════════════════════
323
+ # Version
324
+ # ═══════════════════════════════════════════════════════════════════════════════
325
+
326
+ class VersionTests(LiveServerTestCase):
327
+ """__version__ is importable and looks like semver."""
328
+
329
+ def test_version_format(self):
330
+ from cli import __version__
331
+ parts = __version__.split('.')
332
+ self.assertEqual(len(parts), 3)
333
+ for p in parts:
334
+ self.assertTrue(p.isdigit())
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: drp-cli
3
+ Version: 0.1.3
4
+ Summary: Drop text and files from the command line — get a link instantly.
5
+ Author: Vic Nas
6
+ License: MIT
7
+ Project-URL: Homepage, https://drp.vicnas.me
8
+ Project-URL: Repository, https://github.com/vicnasdev/drp
9
+ Project-URL: Issues, https://github.com/vicnasdev/drp/issues
10
+ Keywords: cli,file-sharing,pastebin,drops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28
24
+ Dynamic: license-file
25
+
26
+ # drp
27
+
28
+ Drop text and files from the command line — get a link instantly.
29
+
30
+ ```
31
+ pip install drp-cli
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ drp setup # configure host & log in
38
+ drp up notes.txt # upload a file → prints URL
39
+ drp up "hello world" # upload text → prints URL
40
+ drp up doc.pdf -k cv # upload with a custom key
41
+ drp get mykey # text → stdout, file → saved to disk
42
+ drp get mykey -o a.txt # save file with custom name
43
+ drp rm mykey # delete a drop
44
+ drp mv mykey newkey # rename a drop
45
+ drp renew mykey # renew a drop's expiry (paid)
46
+ drp ls # list your drops (requires login)
47
+ drp status # show config
48
+ drp --version # show version
49
+ ```
50
+
51
+ ## How it works
52
+
53
+ 1. `drp setup` saves your host URL (default: `https://drp.vicnas.me`) and optionally logs you in
54
+ 2. `drp up` uploads a file or text string and prints the shareable URL
55
+ 3. `drp get` retrieves a drop — text is printed to stdout, files are saved to disk
56
+ 4. Works anonymously or logged in — logged-in users get locked drops, longer expiry, and `drp ls`
57
+
58
+ ## Configuration
59
+
60
+ Config is stored at `~/.config/drp/config.json`:
61
+
62
+ ```json
63
+ {
64
+ "host": "https://drp.vicnas.me",
65
+ "email": "you@example.com"
66
+ }
67
+ ```
68
+
69
+ ## Self-hosted
70
+
71
+ Point the CLI at your own instance:
72
+
73
+ ```bash
74
+ drp setup
75
+ # enter your server URL when prompted
76
+ ```
77
+
78
+ See the [server repo](https://github.com/vicnasdev/drp) for deployment instructions.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,15 @@
1
+ CLI.md
2
+ LICENSE
3
+ README.md
4
+ pyproject.toml
5
+ cli/__init__.py
6
+ cli/api.py
7
+ cli/config.py
8
+ cli/drp.py
9
+ cli/tests.py
10
+ drp_cli.egg-info/PKG-INFO
11
+ drp_cli.egg-info/SOURCES.txt
12
+ drp_cli.egg-info/dependency_links.txt
13
+ drp_cli.egg-info/entry_points.txt
14
+ drp_cli.egg-info/requires.txt
15
+ drp_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ drp = cli.drp:main
@@ -0,0 +1 @@
1
+ requests>=2.28
@@ -0,0 +1 @@
1
+ cli
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "drp-cli"
7
+ dynamic = ["version"]
8
+ description = "Drop text and files from the command line — get a link instantly."
9
+ readme = "CLI.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = ["requests>=2.28"]
13
+ authors = [{name = "Vic Nas"}]
14
+ keywords = ["cli", "file-sharing", "pastebin", "drops"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Utilities",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://drp.vicnas.me"
29
+ Repository = "https://github.com/vicnasdev/drp"
30
+ Issues = "https://github.com/vicnasdev/drp/issues"
31
+
32
+ [project.scripts]
33
+ drp = "cli.drp:main"
34
+
35
+ [tool.setuptools.dynamic]
36
+ version = {attr = "cli.__version__"}
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+