raindrop-cli 0.5.2__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.
- raindrop_cli-0.5.2.dist-info/METADATA +530 -0
- raindrop_cli-0.5.2.dist-info/RECORD +16 -0
- raindrop_cli-0.5.2.dist-info/WHEEL +4 -0
- raindrop_cli-0.5.2.dist-info/entry_points.txt +2 -0
- raindrop_cli-0.5.2.dist-info/licenses/LICENSE +21 -0
- rd_cli/__init__.py +25 -0
- rd_cli/__main__.py +6 -0
- rd_cli/cli.py +757 -0
- rd_cli/client.py +727 -0
- rd_cli/commands.py +1180 -0
- rd_cli/completion.py +225 -0
- rd_cli/config.py +162 -0
- rd_cli/errors.py +56 -0
- rd_cli/output.py +305 -0
- rd_cli/pinboard.py +275 -0
- rd_cli/sync.py +252 -0
rd_cli/completion.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Shell completion generated from the argument parser itself.
|
|
2
|
+
|
|
3
|
+
The parser is the single source of truth for what commands and flags exist, so
|
|
4
|
+
completion is derived from it rather than maintained beside it. A hand-kept
|
|
5
|
+
table would be correct exactly until the next command lands and then quietly rot,
|
|
6
|
+
and a stale completion is worse than none: it offers flags that no longer work.
|
|
7
|
+
|
|
8
|
+
This means reading argparse's private structures (``_actions``,
|
|
9
|
+
``_SubParsersAction``). That is a deliberate trade. The alternative is a second
|
|
10
|
+
declaration of every command, and the risk is bounded: ``probe_argparse_internals``
|
|
11
|
+
states exactly what is being relied on, and ``tests/test_completion.py`` calls it
|
|
12
|
+
so a Python upgrade that moves any of it fails loudly with a pointed message
|
|
13
|
+
instead of silently emitting empty scripts.
|
|
14
|
+
|
|
15
|
+
No third-party dependency is involved (rd-cli is stdlib-only), which also rules
|
|
16
|
+
out argcomplete.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArgparseLayoutError(RuntimeError):
|
|
27
|
+
"""argparse no longer exposes what the completion walker needs."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def probe_argparse_internals() -> None:
|
|
31
|
+
"""Fail loudly if the private argparse API this module walks has moved.
|
|
32
|
+
|
|
33
|
+
Called by the test suite. Everything asserted here is exactly what `_walk`
|
|
34
|
+
below depends on; if this passes, the walker can run.
|
|
35
|
+
"""
|
|
36
|
+
p = argparse.ArgumentParser(prog="probe")
|
|
37
|
+
p.add_argument("--flag")
|
|
38
|
+
sub = p.add_subparsers()
|
|
39
|
+
child = sub.add_parser("child")
|
|
40
|
+
child.add_argument("--inner")
|
|
41
|
+
|
|
42
|
+
if not hasattr(p, "_actions"):
|
|
43
|
+
raise ArgparseLayoutError("ArgumentParser._actions is gone")
|
|
44
|
+
if not hasattr(argparse, "_SubParsersAction"):
|
|
45
|
+
raise ArgparseLayoutError("argparse._SubParsersAction is gone")
|
|
46
|
+
|
|
47
|
+
subs = [a for a in p._actions if isinstance(a, argparse._SubParsersAction)]
|
|
48
|
+
if not subs:
|
|
49
|
+
raise ArgparseLayoutError("subparser actions are no longer in _actions")
|
|
50
|
+
if not isinstance(getattr(subs[0], "choices", None), dict):
|
|
51
|
+
raise ArgparseLayoutError(
|
|
52
|
+
"_SubParsersAction.choices is no longer a name->parser dict"
|
|
53
|
+
)
|
|
54
|
+
if subs[0].choices.get("child") is not child:
|
|
55
|
+
raise ArgparseLayoutError(
|
|
56
|
+
"_SubParsersAction.choices no longer maps to the child parser"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
flags = [a for a in p._actions if a.option_strings]
|
|
60
|
+
if not any("--flag" in a.option_strings for a in flags):
|
|
61
|
+
raise ArgparseLayoutError("Action.option_strings no longer lists flags")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _walk(parser: argparse.ArgumentParser) -> dict:
|
|
65
|
+
"""Reduce a parser to ``{"options", "values", "subcommands"}``."""
|
|
66
|
+
options: list[str] = []
|
|
67
|
+
values: list[str] = []
|
|
68
|
+
subcommands: dict[str, dict] = {}
|
|
69
|
+
|
|
70
|
+
for action in parser._actions:
|
|
71
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
72
|
+
for name, child in action.choices.items():
|
|
73
|
+
subcommands[name] = _walk(child)
|
|
74
|
+
elif action.option_strings:
|
|
75
|
+
options.extend(action.option_strings)
|
|
76
|
+
elif action.choices:
|
|
77
|
+
# A positional with a fixed choice list, e.g. `rd completion bash`.
|
|
78
|
+
# Worth completing and easy to miss: subparsers are also positionals
|
|
79
|
+
# carrying `choices`, so they have to be taken off the table first or
|
|
80
|
+
# every command name lands here as a value too.
|
|
81
|
+
values.extend(str(c) for c in action.choices)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"options": sorted(set(options)),
|
|
85
|
+
"values": sorted(set(values)),
|
|
86
|
+
"subcommands": subcommands,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def describe(parser: argparse.ArgumentParser) -> dict:
|
|
91
|
+
"""The completion model for a parser: the shape every emitter renders."""
|
|
92
|
+
return _walk(parser)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bash(tree: dict, prog: str) -> str:
|
|
96
|
+
top = " ".join(sorted(tree["subcommands"]))
|
|
97
|
+
root_opts = " ".join(tree["options"])
|
|
98
|
+
|
|
99
|
+
# One case arm per command carrying its own flags and nested subcommands.
|
|
100
|
+
arms: list[str] = []
|
|
101
|
+
for name in sorted(tree["subcommands"]):
|
|
102
|
+
node = tree["subcommands"][name]
|
|
103
|
+
nested = " ".join(sorted(node["subcommands"]) + node["values"])
|
|
104
|
+
opts = " ".join(node["options"])
|
|
105
|
+
arms.append(
|
|
106
|
+
f" {name})\n"
|
|
107
|
+
f" __rd_sub='{nested}'\n"
|
|
108
|
+
f" __rd_opts='{opts}'\n"
|
|
109
|
+
f" ;;"
|
|
110
|
+
)
|
|
111
|
+
arm_text = "\n".join(arms)
|
|
112
|
+
|
|
113
|
+
return f"""# {prog} completion for bash. Generated by `{prog} completion bash`.
|
|
114
|
+
# Install: {prog} completion bash > ~/.local/share/bash-completion/completions/{prog}
|
|
115
|
+
_{prog}_complete() {{
|
|
116
|
+
local cur prev cmd i
|
|
117
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
118
|
+
local __rd_sub='' __rd_opts=''
|
|
119
|
+
|
|
120
|
+
# First non-flag word after the program name is the command.
|
|
121
|
+
cmd=''
|
|
122
|
+
for (( i=1; i < COMP_CWORD; i++ )); do
|
|
123
|
+
case "${{COMP_WORDS[i]}}" in
|
|
124
|
+
-*) ;;
|
|
125
|
+
*) cmd="${{COMP_WORDS[i]}}"; break ;;
|
|
126
|
+
esac
|
|
127
|
+
done
|
|
128
|
+
|
|
129
|
+
if [ -z "$cmd" ]; then
|
|
130
|
+
COMPREPLY=( $(compgen -W '{top} {root_opts}' -- "$cur") )
|
|
131
|
+
return 0
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
case "$cmd" in
|
|
135
|
+
{arm_text}
|
|
136
|
+
*) ;;
|
|
137
|
+
esac
|
|
138
|
+
|
|
139
|
+
COMPREPLY=( $(compgen -W "$__rd_sub $__rd_opts" -- "$cur") )
|
|
140
|
+
return 0
|
|
141
|
+
}}
|
|
142
|
+
complete -F _{prog}_complete {prog}
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _zsh(tree: dict, prog: str) -> str:
|
|
147
|
+
lines: list[str] = [
|
|
148
|
+
f"#compdef {prog}",
|
|
149
|
+
f"# {prog} completion for zsh. Generated by `{prog} completion zsh`.",
|
|
150
|
+
f'# Install: {prog} completion zsh > "${{fpath[1]}}/_{prog}"',
|
|
151
|
+
"# (then restart zsh)",
|
|
152
|
+
"",
|
|
153
|
+
f"_{prog}() {{",
|
|
154
|
+
" local -a cmds",
|
|
155
|
+
" cmds=(" + " ".join(f"'{n}'" for n in sorted(tree["subcommands"])) + ")",
|
|
156
|
+
" local -a opts",
|
|
157
|
+
" opts=(" + " ".join(f"'{o}'" for o in tree["options"]) + ")",
|
|
158
|
+
"",
|
|
159
|
+
" if (( CURRENT == 2 )); then",
|
|
160
|
+
" _describe -t commands 'command' cmds",
|
|
161
|
+
" compadd -a opts",
|
|
162
|
+
" return",
|
|
163
|
+
" fi",
|
|
164
|
+
"",
|
|
165
|
+
" case ${words[2]} in",
|
|
166
|
+
]
|
|
167
|
+
for name in sorted(tree["subcommands"]):
|
|
168
|
+
node = tree["subcommands"][name]
|
|
169
|
+
sub = " ".join(f"'{s}'" for s in sorted(node["subcommands"]) + node["values"])
|
|
170
|
+
opt = " ".join(f"'{o}'" for o in node["options"])
|
|
171
|
+
lines.append(f" {name})")
|
|
172
|
+
if sub:
|
|
173
|
+
lines.append(f" local -a s; s=({sub}); compadd -a s")
|
|
174
|
+
lines.append(f" local -a o; o=({opt}); compadd -a o")
|
|
175
|
+
lines.append(" ;;")
|
|
176
|
+
lines += [
|
|
177
|
+
" esac",
|
|
178
|
+
"}",
|
|
179
|
+
"",
|
|
180
|
+
f'_{prog} "$@"',
|
|
181
|
+
"",
|
|
182
|
+
]
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _fish(tree: dict, prog: str) -> str:
|
|
187
|
+
lines: list[str] = [
|
|
188
|
+
f"# {prog} completion for fish. Generated by `{prog} completion fish`.",
|
|
189
|
+
f"# Install: {prog} completion fish > ~/.config/fish/completions/{prog}.fish",
|
|
190
|
+
"",
|
|
191
|
+
# Without this, fish offers file paths alongside every command.
|
|
192
|
+
f"complete -c {prog} -f",
|
|
193
|
+
"",
|
|
194
|
+
]
|
|
195
|
+
for name in sorted(tree["subcommands"]):
|
|
196
|
+
lines.append(f"complete -c {prog} -n __fish_use_subcommand -a {name}")
|
|
197
|
+
lines.append("")
|
|
198
|
+
for name in sorted(tree["subcommands"]):
|
|
199
|
+
node = tree["subcommands"][name]
|
|
200
|
+
for s in sorted(node["subcommands"]) + node["values"]:
|
|
201
|
+
lines.append(
|
|
202
|
+
f"complete -c {prog} -n '__fish_seen_subcommand_from {name}' -a {s}"
|
|
203
|
+
)
|
|
204
|
+
for o in node["options"]:
|
|
205
|
+
seen = f"-n '__fish_seen_subcommand_from {name}'"
|
|
206
|
+
if o.startswith("--"):
|
|
207
|
+
lines.append(f"complete -c {prog} {seen} -l {o[2:]}")
|
|
208
|
+
elif len(o) == 2:
|
|
209
|
+
lines.append(f"complete -c {prog} {seen} -s {o[1:]}")
|
|
210
|
+
lines.append("")
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_EMITTERS = {"bash": _bash, "zsh": _zsh, "fish": _fish}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate(shell: str, parser: argparse.ArgumentParser, prog: str = "rd") -> str:
|
|
218
|
+
"""The completion script for ``shell``, derived from ``parser``."""
|
|
219
|
+
try:
|
|
220
|
+
emit = _EMITTERS[shell]
|
|
221
|
+
except KeyError:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"unknown shell {shell!r} (expected one of: {', '.join(SHELLS)})"
|
|
224
|
+
) from None
|
|
225
|
+
return emit(describe(parser), prog)
|
rd_cli/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Token and configuration resolution.
|
|
2
|
+
|
|
3
|
+
Resolution order for the access token (first hit wins):
|
|
4
|
+
|
|
5
|
+
1. ``RAINDROP_TOKEN`` environment variable.
|
|
6
|
+
2. ``RAINDROP_TEST_TOKEN`` environment variable (back-compat alias).
|
|
7
|
+
3. ``token`` key in ``$XDG_CONFIG_HOME/rd-cli/config.toml``.
|
|
8
|
+
4. ``RAINDROP_TOKEN`` / ``RAINDROP_TEST_TOKEN`` in a ``.env`` file, searched in
|
|
9
|
+
the current directory then ``$XDG_CONFIG_HOME/rd-cli/.env``.
|
|
10
|
+
|
|
11
|
+
The ``.env`` reader is a deliberately tiny stdlib parser so we carry no
|
|
12
|
+
``python-dotenv`` dependency. It only loads keys that are not already in the
|
|
13
|
+
environment, matching python-dotenv's default and keeping real env vars
|
|
14
|
+
authoritative.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import tomllib
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .errors import ConfigError
|
|
24
|
+
|
|
25
|
+
ENV_VARS = ("RAINDROP_TOKEN", "RAINDROP_TEST_TOKEN")
|
|
26
|
+
PINBOARD_ENV_VARS = ("PINBOARD_TOKEN", "PINBOARD_API_TOKEN")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def config_dir() -> Path:
|
|
30
|
+
"""Return the rd-cli config directory (respects ``XDG_CONFIG_HOME``)."""
|
|
31
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
32
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
33
|
+
return root / "rd-cli"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def config_path() -> Path:
|
|
37
|
+
"""Path to ``config.toml`` (may not exist)."""
|
|
38
|
+
return config_dir() / "config.toml"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
42
|
+
"""Parse ``.env`` text into a dict. Supports ``KEY=value``, ``export KEY=v``,
|
|
43
|
+
``#`` comments, blank lines, and single/double quoted values."""
|
|
44
|
+
result: dict[str, str] = {}
|
|
45
|
+
for raw in text.splitlines():
|
|
46
|
+
line = raw.strip()
|
|
47
|
+
if not line or line.startswith("#"):
|
|
48
|
+
continue
|
|
49
|
+
if line.startswith("export "):
|
|
50
|
+
line = line[len("export ") :].lstrip()
|
|
51
|
+
key, sep, value = line.partition("=")
|
|
52
|
+
if not sep:
|
|
53
|
+
continue
|
|
54
|
+
key = key.strip()
|
|
55
|
+
value = value.strip()
|
|
56
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
if key:
|
|
59
|
+
result[key] = value
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_env_files(paths: list[Path] | None = None) -> None:
|
|
64
|
+
"""Load the first existing ``.env`` file into ``os.environ`` (non-clobbering)."""
|
|
65
|
+
if paths is None:
|
|
66
|
+
paths = [Path.cwd() / ".env", config_dir() / ".env"]
|
|
67
|
+
for path in paths:
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
continue
|
|
70
|
+
for key, value in parse_env(path.read_text(encoding="utf-8")).items():
|
|
71
|
+
os.environ.setdefault(key, value)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_config() -> dict:
|
|
76
|
+
"""Read ``config.toml`` as a dict; empty dict if it does not exist."""
|
|
77
|
+
path = config_path()
|
|
78
|
+
if not path.is_file():
|
|
79
|
+
return {}
|
|
80
|
+
try:
|
|
81
|
+
with path.open("rb") as fh:
|
|
82
|
+
return tomllib.load(fh)
|
|
83
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
84
|
+
raise ConfigError(f"Could not read {path}: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_token() -> str:
|
|
88
|
+
"""Resolve the access token, or raise :class:`ConfigError` if none is found."""
|
|
89
|
+
load_env_files()
|
|
90
|
+
for var in ENV_VARS:
|
|
91
|
+
token = os.environ.get(var)
|
|
92
|
+
if token:
|
|
93
|
+
return token.strip()
|
|
94
|
+
token = read_config().get("token")
|
|
95
|
+
if isinstance(token, str) and token.strip():
|
|
96
|
+
return token.strip()
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
"No Raindrop token found. Set RAINDROP_TOKEN, add it to "
|
|
99
|
+
f"{config_path()} via `rd config set-token <token>`, or put it in a "
|
|
100
|
+
".env file. Get a test token at "
|
|
101
|
+
"https://app.raindrop.io/settings/integrations"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_pinboard_token() -> str:
|
|
106
|
+
"""Resolve the Pinboard API token (format ``user:HEX``), or raise.
|
|
107
|
+
|
|
108
|
+
Same precedence as :func:`resolve_token` but for the ``PINBOARD_TOKEN`` /
|
|
109
|
+
``PINBOARD_API_TOKEN`` env vars and the ``pinboard_token`` config key.
|
|
110
|
+
"""
|
|
111
|
+
load_env_files()
|
|
112
|
+
for var in PINBOARD_ENV_VARS:
|
|
113
|
+
token = os.environ.get(var)
|
|
114
|
+
if token:
|
|
115
|
+
return token.strip()
|
|
116
|
+
token = read_config().get("pinboard_token")
|
|
117
|
+
if isinstance(token, str) and token.strip():
|
|
118
|
+
return token.strip()
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
"No Pinboard token found. Set PINBOARD_TOKEN, add it to "
|
|
121
|
+
f"{config_path()} via `rd config set-pinboard-token <token>`, or put it "
|
|
122
|
+
"in a .env file. Your token (format user:HEX) is at "
|
|
123
|
+
"https://pinboard.in/settings/password"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_token(token: str) -> Path:
|
|
128
|
+
"""Persist the Raindrop ``token`` to ``config.toml`` (0600), keeping others."""
|
|
129
|
+
return _write_config_key("token", token)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_pinboard_token(token: str) -> Path:
|
|
133
|
+
"""Persist ``pinboard_token`` to ``config.toml`` (0600), keeping others."""
|
|
134
|
+
return _write_config_key("pinboard_token", token)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _write_config_key(key: str, value: str) -> Path:
|
|
138
|
+
"""Set one string key in ``config.toml`` (0600), preserving every other key.
|
|
139
|
+
The written key is emitted first; order is cosmetic."""
|
|
140
|
+
value = value.strip()
|
|
141
|
+
if not value:
|
|
142
|
+
raise ConfigError(f"Refusing to write an empty {key}.")
|
|
143
|
+
data = read_config()
|
|
144
|
+
data[key] = value
|
|
145
|
+
path = config_path()
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
lines = [_toml_line(key, value)]
|
|
148
|
+
for other, val in data.items():
|
|
149
|
+
if other == key:
|
|
150
|
+
continue
|
|
151
|
+
lines.append(_toml_line(other, val))
|
|
152
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
153
|
+
path.chmod(0o600)
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _toml_line(key: str, value: object) -> str:
|
|
158
|
+
if isinstance(value, bool):
|
|
159
|
+
return f"{key} = {str(value).lower()}"
|
|
160
|
+
if isinstance(value, (int, float)):
|
|
161
|
+
return f"{key} = {value}"
|
|
162
|
+
return f'{key} = "{value}"'
|
rd_cli/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exception hierarchy for rd-cli.
|
|
2
|
+
|
|
3
|
+
Every failure the CLI can surface derives from :class:`RaindropError`, so the
|
|
4
|
+
top-level handler in ``cli.py`` catches one type and prints one clean message.
|
|
5
|
+
API failures carry the parsed ``errorMessage`` the Raindrop API returns, rather
|
|
6
|
+
than the bare HTTP status ``urllib`` would otherwise raise.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RaindropError(Exception):
|
|
13
|
+
"""Base class for every rd-cli error."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(RaindropError):
|
|
17
|
+
"""A token could not be resolved, or config on disk is unreadable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(RaindropError):
|
|
21
|
+
"""The Raindrop API returned a non-success response.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
status: HTTP status code, or ``None`` for transport-level failures.
|
|
25
|
+
message: Human-readable message (the API's ``errorMessage`` when present).
|
|
26
|
+
payload: The decoded JSON body, if any.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
status: int | None = None,
|
|
34
|
+
payload: dict | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status = status
|
|
38
|
+
self.message = message
|
|
39
|
+
self.payload = payload or {}
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
if self.status is not None:
|
|
43
|
+
return f"HTTP {self.status}: {self.message}"
|
|
44
|
+
return self.message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthError(APIError):
|
|
48
|
+
"""Authentication failed (HTTP 401/403) — token missing, wrong, or expired."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFoundError(APIError):
|
|
52
|
+
"""The requested resource does not exist (HTTP 404)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitError(APIError):
|
|
56
|
+
"""Rate limit exceeded (HTTP 429) and retries were exhausted."""
|