docdrop 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.
- docdrop-0.1.0/.gitignore +10 -0
- docdrop-0.1.0/LICENSE +21 -0
- docdrop-0.1.0/PKG-INFO +37 -0
- docdrop-0.1.0/README.md +17 -0
- docdrop-0.1.0/pyproject.toml +44 -0
- docdrop-0.1.0/src/docdrop/__init__.py +3 -0
- docdrop-0.1.0/src/docdrop/__main__.py +5 -0
- docdrop-0.1.0/src/docdrop/cli.py +288 -0
- docdrop-0.1.0/src/docdrop/client.py +104 -0
- docdrop-0.1.0/src/docdrop/config.py +129 -0
- docdrop-0.1.0/src/docdrop/documents.py +57 -0
- docdrop-0.1.0/tests/test_client.py +420 -0
- docdrop-0.1.0/tests/test_config.py +182 -0
- docdrop-0.1.0/tests/test_documents.py +77 -0
- docdrop-0.1.0/tests/test_readme_commands.py +87 -0
- docdrop-0.1.0/uv.lock +203 -0
docdrop-0.1.0/.gitignore
ADDED
docdrop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Owen Campbell
|
|
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
|
|
7
|
+
deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
9
|
+
sell 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
|
|
13
|
+
all 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
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
21
|
+
DEALINGS IN THE SOFTWARE.
|
docdrop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docdrop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Publish HTML and Markdown documents to DocDrop by unguessable link.
|
|
5
|
+
Project-URL: Homepage, https://docdrop.anvil.app
|
|
6
|
+
Project-URL: Repository, https://github.com/Empiria/docdrop
|
|
7
|
+
Project-URL: Documentation, https://github.com/Empiria/docdrop#readme
|
|
8
|
+
Author-email: Owen Campbell <directors@empiria.co.uk>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: markdown,publishing,sharing
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# docdrop
|
|
22
|
+
|
|
23
|
+
`docdrop` is the command-line publisher for [docdrop][repo] — it uploads HTML
|
|
24
|
+
and Markdown documents to a docdrop server and gives you back an unguessable
|
|
25
|
+
link to share.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install docdrop
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Full documentation — installation, CLI configuration and precedence, the
|
|
32
|
+
complete command reference, and how the server side works — lives in the main
|
|
33
|
+
README:
|
|
34
|
+
|
|
35
|
+
**https://github.com/Empiria/docdrop#readme**
|
|
36
|
+
|
|
37
|
+
[repo]: https://github.com/Empiria/docdrop
|
docdrop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# docdrop
|
|
2
|
+
|
|
3
|
+
`docdrop` is the command-line publisher for [docdrop][repo] — it uploads HTML
|
|
4
|
+
and Markdown documents to a docdrop server and gives you back an unguessable
|
|
5
|
+
link to share.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install docdrop
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Full documentation — installation, CLI configuration and precedence, the
|
|
12
|
+
complete command reference, and how the server side works — lives in the main
|
|
13
|
+
README:
|
|
14
|
+
|
|
15
|
+
**https://github.com/Empiria/docdrop#readme**
|
|
16
|
+
|
|
17
|
+
[repo]: https://github.com/Empiria/docdrop
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "docdrop"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Publish HTML and Markdown documents to DocDrop by unguessable link."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Owen Campbell", email = "directors@empiria.co.uk" }]
|
|
9
|
+
keywords = ["publishing", "markdown", "sharing"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Utilities",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["httpx>=0.27"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://docdrop.anvil.app"
|
|
21
|
+
Repository = "https://github.com/Empiria/docdrop"
|
|
22
|
+
Documentation = "https://github.com/Empiria/docdrop#readme"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
docdrop = "docdrop.cli:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/docdrop"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["pytest>=8", "respx>=0.21", "ruff>=0.6"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I001", "SIM", "C4", "PERF", "RET", "UP", "C90"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.isort]
|
|
44
|
+
known-first-party = ["docdrop"]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""docdrop — publish HTML and Markdown documents by unguessable link.
|
|
2
|
+
|
|
3
|
+
Every command accepts a global [--server NAME] prefix to target a named
|
|
4
|
+
server profile from your config (see 'docdrop servers').
|
|
5
|
+
|
|
6
|
+
docdrop [--server NAME] publish <file> [--slug SLUG] [--title TITLE]
|
|
7
|
+
[--alias NAME | --clear-alias]
|
|
8
|
+
docdrop [--server NAME] list
|
|
9
|
+
docdrop [--server NAME] open <slug> [--token]
|
|
10
|
+
docdrop [--server NAME] alias <slug> [name] [--clear]
|
|
11
|
+
docdrop [--server NAME] grant <slug> [--label NAME] [--expires WHEN]
|
|
12
|
+
docdrop [--server NAME] revoke <slug> <handle>
|
|
13
|
+
docdrop [--server NAME] grants <slug>
|
|
14
|
+
docdrop [--server NAME] history <slug>
|
|
15
|
+
docdrop [--server NAME] diff <slug> [A B]
|
|
16
|
+
docdrop [--server NAME] rollback <slug> N
|
|
17
|
+
docdrop servers
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
import webbrowser
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import config as config_module
|
|
26
|
+
from .client import Client, DocDropError
|
|
27
|
+
from .config import ConfigError
|
|
28
|
+
from .documents import UnsupportedFile, content_type_of, slug_of, title_of
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def publish(client: Client, args: argparse.Namespace) -> int:
|
|
32
|
+
path: Path = args.file
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
raise DocDropError(f"No such file: {path}")
|
|
35
|
+
|
|
36
|
+
content_type = content_type_of(path)
|
|
37
|
+
content = path.read_text()
|
|
38
|
+
slug = args.slug or slug_of(path)
|
|
39
|
+
title = args.title or title_of(content, content_type) or slug
|
|
40
|
+
|
|
41
|
+
result = client.publish(
|
|
42
|
+
slug,
|
|
43
|
+
title,
|
|
44
|
+
content,
|
|
45
|
+
content_type,
|
|
46
|
+
alias=args.alias,
|
|
47
|
+
clear_alias=args.clear_alias,
|
|
48
|
+
)
|
|
49
|
+
print(f"{result['action']}: {result['title']} ({result['content_type']})")
|
|
50
|
+
if result.get("alias_url"):
|
|
51
|
+
print(result["alias_url"])
|
|
52
|
+
print(result["url"])
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_documents(client: Client, args: argparse.Namespace) -> int:
|
|
57
|
+
documents = client.documents()
|
|
58
|
+
if not documents:
|
|
59
|
+
print("No documents published yet.")
|
|
60
|
+
return 0
|
|
61
|
+
for doc in documents:
|
|
62
|
+
print(f"{doc['slug']:24} {doc['updated'][:16]:17} {doc['url']}")
|
|
63
|
+
print(f"{'':24} {doc['title']}")
|
|
64
|
+
if doc.get("alias_url"):
|
|
65
|
+
print(f"{'':24} {doc['alias_url']}")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def open_document(client: Client, args: argparse.Namespace) -> int:
|
|
70
|
+
for doc in client.documents():
|
|
71
|
+
if doc["slug"] == args.slug:
|
|
72
|
+
url = (
|
|
73
|
+
doc["url"]
|
|
74
|
+
if args.token or not doc.get("alias_url")
|
|
75
|
+
else doc["alias_url"]
|
|
76
|
+
)
|
|
77
|
+
print(url)
|
|
78
|
+
webbrowser.open(url)
|
|
79
|
+
return 0
|
|
80
|
+
raise DocDropError(f"You have no document with the slug {args.slug!r}.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def alias_command(client: Client, args: argparse.Namespace) -> int:
|
|
84
|
+
result = client.alias(args.slug, args.name, args.clear)
|
|
85
|
+
if args.name:
|
|
86
|
+
print(result["alias_url"])
|
|
87
|
+
print(result["url"])
|
|
88
|
+
elif args.clear:
|
|
89
|
+
print(f"{args.slug}: alias cleared")
|
|
90
|
+
elif result.get("alias"):
|
|
91
|
+
print(result["alias_url"])
|
|
92
|
+
else:
|
|
93
|
+
print(f"no alias set for {args.slug}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def grant_command(client: Client, args: argparse.Namespace) -> int:
|
|
98
|
+
result = client.grant(args.slug, args.label, args.expires)
|
|
99
|
+
print(result["url"])
|
|
100
|
+
print(f"handle: {result['handle']} label: {result['label']}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def revoke_command(client: Client, args: argparse.Namespace) -> int:
|
|
105
|
+
client.revoke(args.slug, args.handle)
|
|
106
|
+
print(f"{args.slug}: revoked {args.handle}")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def grants_command(client: Client, args: argparse.Namespace) -> int:
|
|
111
|
+
grants = client.grants(args.slug)
|
|
112
|
+
if not grants:
|
|
113
|
+
print(f"No grants for {args.slug}.")
|
|
114
|
+
return 0
|
|
115
|
+
for grant in grants:
|
|
116
|
+
last_viewed = grant["last_viewed"][:16] if grant.get("last_viewed") else "never"
|
|
117
|
+
expires = grant["expires"][:16] if grant.get("expires") else "never"
|
|
118
|
+
marker = " (revoked)" if grant.get("revoked") else ""
|
|
119
|
+
print(
|
|
120
|
+
f"{grant['handle']:10} {grant.get('label', ''):14} "
|
|
121
|
+
f"views:{grant['view_count']:<4} last:{last_viewed:17}{marker}"
|
|
122
|
+
)
|
|
123
|
+
print(f"{'':10} created:{grant['created'][:16]:17} expires:{expires}")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def history_command(client: Client, args: argparse.Namespace) -> int:
|
|
128
|
+
revisions = client.history(args.slug)
|
|
129
|
+
if not revisions:
|
|
130
|
+
print(f"No revisions for {args.slug}.")
|
|
131
|
+
return 0
|
|
132
|
+
for r in revisions:
|
|
133
|
+
marker = " (head)" if r.get("head") else ""
|
|
134
|
+
print(
|
|
135
|
+
f"{r['number']:<4} {r['published'][:16]:17} "
|
|
136
|
+
f"{r['content_type']:14}{marker}"
|
|
137
|
+
)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def diff_command(client: Client, args: argparse.Namespace) -> int:
|
|
142
|
+
if args.revisions and len(args.revisions) != 2:
|
|
143
|
+
raise DocDropError("diff takes zero or two revision numbers.")
|
|
144
|
+
a, b = args.revisions if args.revisions else (None, None)
|
|
145
|
+
result = client.diff(args.slug, a, b)
|
|
146
|
+
print(result["diff"], end="")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def rollback_command(client: Client, args: argparse.Namespace) -> int:
|
|
151
|
+
result = client.rollback(args.slug, args.number)
|
|
152
|
+
print(f"{args.slug}: rolled back to {args.number}, new head is {result['number']}")
|
|
153
|
+
print(result["url"])
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def servers_command(args: argparse.Namespace) -> int:
|
|
158
|
+
names, active = config_module.list_servers(server=args.server)
|
|
159
|
+
for name in names:
|
|
160
|
+
marker = "* " if name == active else " "
|
|
161
|
+
print(f"{marker}{name}")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
166
|
+
parser = argparse.ArgumentParser(
|
|
167
|
+
prog="docdrop",
|
|
168
|
+
description="Publish HTML and Markdown documents by unguessable link.",
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument("--server", help="named profile (see 'docdrop servers')")
|
|
171
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
172
|
+
|
|
173
|
+
publish_cmd = commands.add_parser(
|
|
174
|
+
"publish", help="publish an HTML or Markdown file"
|
|
175
|
+
)
|
|
176
|
+
publish_cmd.add_argument("file", type=Path, help="the file to publish")
|
|
177
|
+
publish_cmd.add_argument(
|
|
178
|
+
"--slug", help="URL-stable document key (default: the file's name)"
|
|
179
|
+
)
|
|
180
|
+
publish_cmd.add_argument(
|
|
181
|
+
"--title", help="document title (default: the <title>, or the Markdown H1)"
|
|
182
|
+
)
|
|
183
|
+
alias_group = publish_cmd.add_mutually_exclusive_group()
|
|
184
|
+
alias_group.add_argument("--alias", help="set or edit the document's alias")
|
|
185
|
+
alias_group.add_argument(
|
|
186
|
+
"--clear-alias",
|
|
187
|
+
action="store_true",
|
|
188
|
+
help="remove the document's alias",
|
|
189
|
+
)
|
|
190
|
+
publish_cmd.set_defaults(run=publish)
|
|
191
|
+
|
|
192
|
+
list_cmd = commands.add_parser("list", help="list the documents you've published")
|
|
193
|
+
list_cmd.set_defaults(run=list_documents)
|
|
194
|
+
|
|
195
|
+
servers_cmd = commands.add_parser(
|
|
196
|
+
"servers", help="list your configured server profiles"
|
|
197
|
+
)
|
|
198
|
+
servers_cmd.set_defaults(run=servers_command)
|
|
199
|
+
|
|
200
|
+
open_cmd = commands.add_parser("open", help="open a document in your browser")
|
|
201
|
+
open_cmd.add_argument("slug", help="the document's slug")
|
|
202
|
+
open_cmd.add_argument(
|
|
203
|
+
"--token",
|
|
204
|
+
action="store_true",
|
|
205
|
+
help="open the token URL even when an alias exists",
|
|
206
|
+
)
|
|
207
|
+
open_cmd.set_defaults(run=open_document)
|
|
208
|
+
|
|
209
|
+
alias_cmd = commands.add_parser(
|
|
210
|
+
"alias", help="add, edit, clear, or show a document's alias"
|
|
211
|
+
)
|
|
212
|
+
alias_cmd.add_argument("slug", help="the document's slug")
|
|
213
|
+
alias_cmd.add_argument(
|
|
214
|
+
"name", nargs="?", help="the alias to set (omit to print the current alias)"
|
|
215
|
+
)
|
|
216
|
+
alias_cmd.add_argument("--clear", action="store_true", help="remove the alias")
|
|
217
|
+
alias_cmd.set_defaults(run=alias_command)
|
|
218
|
+
|
|
219
|
+
grant_cmd = commands.add_parser(
|
|
220
|
+
"grant", help="mint a new share link for a document"
|
|
221
|
+
)
|
|
222
|
+
grant_cmd.add_argument("slug", help="the document's slug")
|
|
223
|
+
grant_cmd.add_argument(
|
|
224
|
+
"--label", help="a name for this link (default: auto-assigned)"
|
|
225
|
+
)
|
|
226
|
+
grant_cmd.add_argument(
|
|
227
|
+
"--expires",
|
|
228
|
+
help=(
|
|
229
|
+
"when this link expires: a duration (7d, 24h) or an ISO date "
|
|
230
|
+
"(2026-08-01); omit for a link that never expires"
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
grant_cmd.set_defaults(run=grant_command)
|
|
234
|
+
|
|
235
|
+
revoke_cmd = commands.add_parser("revoke", help="revoke a document's share link")
|
|
236
|
+
revoke_cmd.add_argument("slug", help="the document's slug")
|
|
237
|
+
revoke_cmd.add_argument(
|
|
238
|
+
"handle", help="the link's short handle, from `docdrop grants`"
|
|
239
|
+
)
|
|
240
|
+
revoke_cmd.set_defaults(run=revoke_command)
|
|
241
|
+
|
|
242
|
+
grants_cmd = commands.add_parser("grants", help="list a document's share links")
|
|
243
|
+
grants_cmd.add_argument("slug", help="the document's slug")
|
|
244
|
+
grants_cmd.set_defaults(run=grants_command)
|
|
245
|
+
|
|
246
|
+
history_cmd = commands.add_parser("history", help="list a document's revisions")
|
|
247
|
+
history_cmd.add_argument("slug", help="the document's slug")
|
|
248
|
+
history_cmd.set_defaults(run=history_command)
|
|
249
|
+
|
|
250
|
+
diff_cmd = commands.add_parser(
|
|
251
|
+
"diff", help="show what changed between two revisions"
|
|
252
|
+
)
|
|
253
|
+
diff_cmd.add_argument("slug", help="the document's slug")
|
|
254
|
+
diff_cmd.add_argument(
|
|
255
|
+
"revisions",
|
|
256
|
+
nargs="*",
|
|
257
|
+
type=int,
|
|
258
|
+
metavar="N",
|
|
259
|
+
help="two revision numbers to diff (default: previous vs. head)",
|
|
260
|
+
)
|
|
261
|
+
diff_cmd.set_defaults(run=diff_command)
|
|
262
|
+
|
|
263
|
+
rollback_cmd = commands.add_parser(
|
|
264
|
+
"rollback", help="make an old revision the new head"
|
|
265
|
+
)
|
|
266
|
+
rollback_cmd.add_argument("slug", help="the document's slug")
|
|
267
|
+
rollback_cmd.add_argument(
|
|
268
|
+
"number", type=int, help="the revision number to roll back to"
|
|
269
|
+
)
|
|
270
|
+
rollback_cmd.set_defaults(run=rollback_command)
|
|
271
|
+
|
|
272
|
+
return parser
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv: list[str] | None = None) -> int:
|
|
276
|
+
args = build_parser().parse_args(argv)
|
|
277
|
+
try:
|
|
278
|
+
if args.command == "servers":
|
|
279
|
+
return args.run(args)
|
|
280
|
+
client = Client(config_module.load(server=args.server))
|
|
281
|
+
return args.run(client, args)
|
|
282
|
+
except (ConfigError, DocDropError, UnsupportedFile) as exc:
|
|
283
|
+
print(exc, file=sys.stderr)
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The HTTP client for a DocDrop app."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
|
|
7
|
+
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DocDropError(Exception):
|
|
11
|
+
"""Something the user needs to read: a refusal, or an unreachable app."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Client:
|
|
15
|
+
def __init__(self, config: Config):
|
|
16
|
+
self._config = config
|
|
17
|
+
|
|
18
|
+
def publish(
|
|
19
|
+
self,
|
|
20
|
+
slug: str,
|
|
21
|
+
title: str,
|
|
22
|
+
content: str,
|
|
23
|
+
content_type: str,
|
|
24
|
+
alias: str | None = None,
|
|
25
|
+
clear_alias: bool = False,
|
|
26
|
+
) -> dict:
|
|
27
|
+
body = {
|
|
28
|
+
"slug": slug,
|
|
29
|
+
"title": title,
|
|
30
|
+
"content": content,
|
|
31
|
+
"content_type": content_type,
|
|
32
|
+
}
|
|
33
|
+
if clear_alias:
|
|
34
|
+
body["clear_alias"] = True
|
|
35
|
+
elif alias is not None:
|
|
36
|
+
body["alias"] = alias
|
|
37
|
+
return self._call("POST", "/api/publish", json=body)
|
|
38
|
+
|
|
39
|
+
def alias(self, slug: str, name: str | None = None, clear: bool = False) -> dict:
|
|
40
|
+
body = {"slug": slug}
|
|
41
|
+
if clear:
|
|
42
|
+
body["clear"] = True
|
|
43
|
+
elif name is not None:
|
|
44
|
+
body["name"] = name
|
|
45
|
+
return self._call("POST", "/api/alias", json=body)
|
|
46
|
+
|
|
47
|
+
def documents(self) -> list[dict]:
|
|
48
|
+
return self._call("GET", "/api/documents")["documents"]
|
|
49
|
+
|
|
50
|
+
def grant(
|
|
51
|
+
self, slug: str, label: str | None = None, expires: str | None = None
|
|
52
|
+
) -> dict:
|
|
53
|
+
body = {"slug": slug}
|
|
54
|
+
if label is not None:
|
|
55
|
+
body["label"] = label
|
|
56
|
+
if expires is not None:
|
|
57
|
+
body["expires"] = expires
|
|
58
|
+
return self._call("POST", "/api/grant", json=body)
|
|
59
|
+
|
|
60
|
+
def revoke(self, slug: str, handle: str) -> dict:
|
|
61
|
+
return self._call(
|
|
62
|
+
"POST", "/api/revoke", json={"slug": slug, "handle": handle}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def grants(self, slug: str) -> list[dict]:
|
|
66
|
+
return self._call("GET", "/api/grants", params={"slug": slug})["grants"]
|
|
67
|
+
|
|
68
|
+
def history(self, slug: str) -> list[dict]:
|
|
69
|
+
return self._call("GET", "/api/history", params={"slug": slug})["revisions"]
|
|
70
|
+
|
|
71
|
+
def diff(self, slug: str, a: int | None = None, b: int | None = None) -> dict:
|
|
72
|
+
params = {"slug": slug}
|
|
73
|
+
if a is not None and b is not None:
|
|
74
|
+
params["a"] = a
|
|
75
|
+
params["b"] = b
|
|
76
|
+
return self._call("GET", "/api/diff", params=params)
|
|
77
|
+
|
|
78
|
+
def rollback(self, slug: str, number: int) -> dict:
|
|
79
|
+
return self._call(
|
|
80
|
+
"POST", "/api/rollback", json={"slug": slug, "number": number}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _call(self, method: str, path: str, **kwargs) -> dict:
|
|
84
|
+
url = f"{self._config.url}{path}"
|
|
85
|
+
headers = {"Authorization": f"Bearer {self._config.api_key}"}
|
|
86
|
+
try:
|
|
87
|
+
response = httpx.request(
|
|
88
|
+
method, url, headers=headers, timeout=TIMEOUT, **kwargs
|
|
89
|
+
)
|
|
90
|
+
except httpx.RequestError as exc:
|
|
91
|
+
raise DocDropError(f"Could not reach {self._config.url}: {exc}") from None
|
|
92
|
+
|
|
93
|
+
if response.is_success:
|
|
94
|
+
return response.json()
|
|
95
|
+
|
|
96
|
+
# The API reports refusals as {"error": "..."}; anything else (a proxy
|
|
97
|
+
# error page, say) has no message worth relaying, so show the status.
|
|
98
|
+
try:
|
|
99
|
+
message = response.json().get("error")
|
|
100
|
+
except ValueError:
|
|
101
|
+
message = None
|
|
102
|
+
raise DocDropError(
|
|
103
|
+
message or f"{self._config.url} returned HTTP {response.status_code}."
|
|
104
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Where the API key and server URL live.
|
|
2
|
+
|
|
3
|
+
Config is read from $DOCDROP_CONFIG, else $XDG_CONFIG_HOME/docdrop/config.toml,
|
|
4
|
+
else ~/.config/docdrop/config.toml.
|
|
5
|
+
|
|
6
|
+
With named `[servers.<name>]` profiles, pick one with `--server` (or omit it
|
|
7
|
+
and let the sole profile auto-default, or the one marked `default = true`
|
|
8
|
+
when there are several):
|
|
9
|
+
|
|
10
|
+
[servers.production]
|
|
11
|
+
url = "https://docdrop.anvil.app"
|
|
12
|
+
api_key = "..."
|
|
13
|
+
|
|
14
|
+
[servers.local]
|
|
15
|
+
url = "http://localhost:3030"
|
|
16
|
+
api_key = "..."
|
|
17
|
+
default = true
|
|
18
|
+
|
|
19
|
+
With no `[servers.*]` tables, the flat top-level keys are used instead (and
|
|
20
|
+
DOCDROP_API_KEY / DOCDROP_URL, if set, override them):
|
|
21
|
+
|
|
22
|
+
api_key = "..."
|
|
23
|
+
url = "https://docdrop.anvil.app"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import tomllib
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
api_key: str
|
|
39
|
+
url: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_path() -> Path:
|
|
43
|
+
if override := os.environ.get("DOCDROP_CONFIG"):
|
|
44
|
+
return Path(override)
|
|
45
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
46
|
+
return Path(base) / "docdrop" / "config.toml"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_stored(path: Path) -> dict:
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
return tomllib.loads(path.read_text())
|
|
54
|
+
except tomllib.TOMLDecodeError as exc:
|
|
55
|
+
raise ConfigError(f"{path} is not valid TOML: {exc}") from None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _finalize(api_key: str | None, url: str | None, path: Path) -> Config:
|
|
59
|
+
if not api_key or not url:
|
|
60
|
+
raise ConfigError(
|
|
61
|
+
f"Incomplete config: both api_key and url are required (see {path})."
|
|
62
|
+
)
|
|
63
|
+
return Config(api_key=api_key, url=url.rstrip("/"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _servers_or_raise(server: str, servers: dict, path: Path) -> dict:
|
|
67
|
+
"""Look up a named profile table, raising ConfigError if not found."""
|
|
68
|
+
if not servers:
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
f"No server profiles defined in {path}. Add a [servers.{server}] "
|
|
71
|
+
"table, or omit --server."
|
|
72
|
+
)
|
|
73
|
+
if server not in servers:
|
|
74
|
+
names = ", ".join(sorted(servers))
|
|
75
|
+
raise ConfigError(f"No profile '{server}' (have: {names}).")
|
|
76
|
+
return servers[server]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_name(servers: dict) -> str | None:
|
|
80
|
+
"""Resolve the implicit default profile name, or None if ambiguous."""
|
|
81
|
+
if len(servers) == 1:
|
|
82
|
+
return next(iter(servers))
|
|
83
|
+
marked = [name for name, table in servers.items() if table.get("default") is True]
|
|
84
|
+
return marked[0] if len(marked) == 1 else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _default_table(servers: dict, path: Path) -> dict:
|
|
88
|
+
name = _default_name(servers)
|
|
89
|
+
if name is None:
|
|
90
|
+
raise ConfigError(
|
|
91
|
+
f"{len(servers)} server profiles in {path} but the default is "
|
|
92
|
+
f"ambiguous (have: {', '.join(sorted(servers))}). Mark exactly "
|
|
93
|
+
"one default = true, or pass --server."
|
|
94
|
+
)
|
|
95
|
+
return servers[name]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load(server: str | None = None) -> Config:
|
|
99
|
+
path = config_path()
|
|
100
|
+
stored = _read_stored(path)
|
|
101
|
+
servers = stored.get("servers", {})
|
|
102
|
+
|
|
103
|
+
if server is not None:
|
|
104
|
+
table = _servers_or_raise(server, servers, path)
|
|
105
|
+
return _finalize(table.get("api_key"), table.get("url"), path)
|
|
106
|
+
|
|
107
|
+
if servers:
|
|
108
|
+
table = _default_table(servers, path)
|
|
109
|
+
return _finalize(table.get("api_key"), table.get("url"), path)
|
|
110
|
+
|
|
111
|
+
api_key = os.environ.get("DOCDROP_API_KEY") or stored.get("api_key")
|
|
112
|
+
url = os.environ.get("DOCDROP_URL") or stored.get("url")
|
|
113
|
+
return _finalize(api_key, url, path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def list_servers(server: str | None = None) -> tuple[list[str], str | None]:
|
|
117
|
+
"""Return (profile names, active name) without ever touching an api_key."""
|
|
118
|
+
path = config_path()
|
|
119
|
+
stored = _read_stored(path)
|
|
120
|
+
servers = stored.get("servers", {})
|
|
121
|
+
|
|
122
|
+
if not servers and server is None:
|
|
123
|
+
return ["(default)"], "(default)"
|
|
124
|
+
|
|
125
|
+
if server is not None:
|
|
126
|
+
_servers_or_raise(server, servers, path)
|
|
127
|
+
return sorted(servers), server
|
|
128
|
+
|
|
129
|
+
return sorted(servers), _default_name(servers)
|