plex-axi 0.1.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.
- plex_axi/__init__.py +6 -0
- plex_axi/__main__.py +10 -0
- plex_axi/argspec.py +312 -0
- plex_axi/cli.py +483 -0
- plex_axi/commands/__init__.py +13 -0
- plex_axi/commands/_common.py +116 -0
- plex_axi/commands/api.py +205 -0
- plex_axi/commands/doctor.py +146 -0
- plex_axi/commands/genres.py +124 -0
- plex_axi/commands/home.py +158 -0
- plex_axi/commands/item.py +328 -0
- plex_axi/commands/recent.py +96 -0
- plex_axi/commands/search.py +210 -0
- plex_axi/commands/sessions.py +80 -0
- plex_axi/commands/similar.py +169 -0
- plex_axi/commands/skill.py +78 -0
- plex_axi/config.py +183 -0
- plex_axi/errors.py +55 -0
- plex_axi/ids.py +129 -0
- plex_axi/music.py +574 -0
- plex_axi/output.py +254 -0
- plex_axi/plex.py +284 -0
- plex_axi/skill.py +137 -0
- plex_axi/toon.py +311 -0
- plex_axi-0.1.0.dist-info/METADATA +216 -0
- plex_axi-0.1.0.dist-info/RECORD +29 -0
- plex_axi-0.1.0.dist-info/WHEEL +4 -0
- plex_axi-0.1.0.dist-info/entry_points.txt +2 -0
- plex_axi-0.1.0.dist-info/licenses/LICENSE +21 -0
plex_axi/__init__.py
ADDED
plex_axi/__main__.py
ADDED
plex_axi/argspec.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Command declarations, argument parsing and ``--help`` rendering.
|
|
2
|
+
|
|
3
|
+
Every command declares its own flags per subcommand. Anything undeclared is
|
|
4
|
+
rejected by name with the subcommand's valid flags printed inline, so an agent
|
|
5
|
+
that guessed wrong corrects itself in one turn rather than two.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import UsageError
|
|
14
|
+
|
|
15
|
+
#: Flags accepted on every command, and therefore never reported as unknown.
|
|
16
|
+
GLOBAL_FLAGS = (
|
|
17
|
+
"--help",
|
|
18
|
+
"-h",
|
|
19
|
+
"--human",
|
|
20
|
+
"--json",
|
|
21
|
+
"--timeout",
|
|
22
|
+
"--section",
|
|
23
|
+
"--debug",
|
|
24
|
+
"--version",
|
|
25
|
+
"-v",
|
|
26
|
+
"-V",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: The globals that consume the next token. Declared once so the parser, the
|
|
30
|
+
#: pre-scan and `--help` detection cannot disagree about where a value ends.
|
|
31
|
+
VALUE_GLOBALS = ("--timeout", "--section")
|
|
32
|
+
|
|
33
|
+
#: How each value-taking global is spelled back in a usage error.
|
|
34
|
+
_GLOBAL_EXAMPLE = {
|
|
35
|
+
"--timeout": "plex-axi --timeout 60 <command>",
|
|
36
|
+
"--section": "plex-axi --section 'Example Music' <command>",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#: Flags an agent might plausibly reach for, mapped to what actually exists.
|
|
40
|
+
#: A targeted hint beats the generic list when the intent is unambiguous.
|
|
41
|
+
RENAMED: dict = {
|
|
42
|
+
# Video vocabulary, which is what every other Plex tool in the landscape is
|
|
43
|
+
# shaped around. Pointing it at the music equivalent is more useful than the
|
|
44
|
+
# generic list, because the agent's guess says exactly what it meant.
|
|
45
|
+
"--title": "--track",
|
|
46
|
+
"--name": "--track",
|
|
47
|
+
"--song": "--track",
|
|
48
|
+
"--band": "--artist",
|
|
49
|
+
"--albumartist": "--artist",
|
|
50
|
+
"--album-artist": "--artist",
|
|
51
|
+
"--record": "--album",
|
|
52
|
+
"--search": "--query",
|
|
53
|
+
"--text": "--query",
|
|
54
|
+
"--rating": "--rated-min",
|
|
55
|
+
"--min-rating": "--rated-min",
|
|
56
|
+
"--stars": "--rated-min",
|
|
57
|
+
"--count": "--limit",
|
|
58
|
+
"--max": "--limit",
|
|
59
|
+
"--top": "--limit",
|
|
60
|
+
"--kind": "--type",
|
|
61
|
+
"--libtype": "--type",
|
|
62
|
+
"--format": "--fields",
|
|
63
|
+
"--output": "--fields",
|
|
64
|
+
"--group": "--no-group",
|
|
65
|
+
"--dedupe": "--no-group",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Flag:
|
|
71
|
+
"""One declared flag on one subcommand."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
metavar: str = ""
|
|
75
|
+
repeat: bool = False
|
|
76
|
+
default: Any = None
|
|
77
|
+
boolean: bool = False
|
|
78
|
+
note: str = ""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def takes_value(self) -> bool:
|
|
82
|
+
return not self.boolean
|
|
83
|
+
|
|
84
|
+
def render(self) -> str:
|
|
85
|
+
parts = [self.name]
|
|
86
|
+
if self.metavar:
|
|
87
|
+
parts.append(self.metavar)
|
|
88
|
+
text = " ".join(parts)
|
|
89
|
+
extras = []
|
|
90
|
+
if self.repeat:
|
|
91
|
+
extras.append("repeatable")
|
|
92
|
+
if self.default not in (None, False):
|
|
93
|
+
extras.append(f"default {self.default}")
|
|
94
|
+
if self.note:
|
|
95
|
+
extras.append(self.note)
|
|
96
|
+
return f"{text} ({', '.join(extras)})" if extras else text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Sub:
|
|
101
|
+
"""One subcommand: its positional arguments and its flag set."""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
args: tuple = ()
|
|
105
|
+
flags: tuple = ()
|
|
106
|
+
summary: str = ""
|
|
107
|
+
|
|
108
|
+
def signature(self) -> str:
|
|
109
|
+
return " ".join([self.name, *self.args]) if self.args else self.name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Command:
|
|
114
|
+
"""A top-level command grouping subcommands under one noun."""
|
|
115
|
+
|
|
116
|
+
name: str
|
|
117
|
+
summary: str
|
|
118
|
+
subs: tuple = ()
|
|
119
|
+
examples: tuple = ()
|
|
120
|
+
default_sub: str | None = None
|
|
121
|
+
notes: tuple = ()
|
|
122
|
+
usage: str = ""
|
|
123
|
+
|
|
124
|
+
def find(self, name: str) -> Sub | None:
|
|
125
|
+
for sub in self.subs:
|
|
126
|
+
if sub.name == name:
|
|
127
|
+
return sub
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Parsed:
|
|
133
|
+
"""The result of parsing one invocation."""
|
|
134
|
+
|
|
135
|
+
positionals: list = field(default_factory=list)
|
|
136
|
+
flags: dict = field(default_factory=dict)
|
|
137
|
+
globals: dict = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
def get(self, name: str, default=None):
|
|
140
|
+
return self.flags.get(_key(name), default)
|
|
141
|
+
|
|
142
|
+
def has(self, name: str) -> bool:
|
|
143
|
+
return _key(name) in self.flags
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _key(flag_name: str) -> str:
|
|
147
|
+
return flag_name.lstrip("-").replace("-", "_")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse(sub: Sub, argv: list, *, command: Command) -> Parsed:
|
|
151
|
+
"""Parse ``argv`` against ``sub``'s declaration, rejecting anything undeclared."""
|
|
152
|
+
declared = {flag.name: flag for flag in sub.flags}
|
|
153
|
+
result = Parsed()
|
|
154
|
+
for flag in sub.flags:
|
|
155
|
+
if flag.repeat:
|
|
156
|
+
result.flags[_key(flag.name)] = []
|
|
157
|
+
elif flag.boolean:
|
|
158
|
+
result.flags[_key(flag.name)] = False
|
|
159
|
+
elif flag.default is not None:
|
|
160
|
+
result.flags[_key(flag.name)] = flag.default
|
|
161
|
+
|
|
162
|
+
index = 0
|
|
163
|
+
while index < len(argv):
|
|
164
|
+
token = argv[index]
|
|
165
|
+
index += 1
|
|
166
|
+
|
|
167
|
+
if token == "--":
|
|
168
|
+
result.positionals.extend(argv[index:])
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not token.startswith("-") or token == "-":
|
|
172
|
+
result.positionals.append(token)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
name, _, inline = token.partition("=")
|
|
176
|
+
has_inline = bool(_)
|
|
177
|
+
|
|
178
|
+
if name in GLOBAL_FLAGS:
|
|
179
|
+
# Globals are accepted after the subcommand as well as before it.
|
|
180
|
+
# They are recorded rather than rejected, and applied by the caller.
|
|
181
|
+
if name in VALUE_GLOBALS:
|
|
182
|
+
key = name.lstrip("-")
|
|
183
|
+
if has_inline:
|
|
184
|
+
result.globals[key] = inline
|
|
185
|
+
elif index < len(argv):
|
|
186
|
+
result.globals[key] = argv[index]
|
|
187
|
+
index += 1
|
|
188
|
+
else:
|
|
189
|
+
raise UsageError(
|
|
190
|
+
f"{name} needs a value",
|
|
191
|
+
help_lines=[f"Run `{_GLOBAL_EXAMPLE[name]}`"],
|
|
192
|
+
code=f"BAD_{key.upper()}",
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
result.globals[name.lstrip("-")] = True
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
flag = declared.get(name)
|
|
199
|
+
if flag is None:
|
|
200
|
+
raise _unknown_flag(name, sub, command)
|
|
201
|
+
|
|
202
|
+
if flag.boolean:
|
|
203
|
+
if has_inline and inline.lower() in ("false", "0", "no"):
|
|
204
|
+
result.flags[_key(name)] = False
|
|
205
|
+
else:
|
|
206
|
+
result.flags[_key(name)] = True
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if has_inline:
|
|
210
|
+
value = inline
|
|
211
|
+
else:
|
|
212
|
+
if index >= len(argv):
|
|
213
|
+
raise UsageError(
|
|
214
|
+
f"{name} needs a value",
|
|
215
|
+
help_lines=[
|
|
216
|
+
f"Run `{_invocation(command, sub)} {name} {flag.metavar or '<value>'}`"
|
|
217
|
+
],
|
|
218
|
+
code="MISSING_VALUE",
|
|
219
|
+
)
|
|
220
|
+
value = argv[index]
|
|
221
|
+
index += 1
|
|
222
|
+
|
|
223
|
+
if flag.repeat:
|
|
224
|
+
result.flags[_key(name)].append(value)
|
|
225
|
+
else:
|
|
226
|
+
result.flags[_key(name)] = value
|
|
227
|
+
|
|
228
|
+
_check_positionals(sub, command, result.positionals)
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _label(command: Command, sub: Sub) -> str:
|
|
233
|
+
"""How one subcommand is named in a message.
|
|
234
|
+
|
|
235
|
+
Most nouns here have a single subcommand named after the noun itself, so
|
|
236
|
+
spelling both would render `search search` -- which is not a command anyone
|
|
237
|
+
can run, and an agent copying it would fail twice.
|
|
238
|
+
"""
|
|
239
|
+
if sub.name == command.name or (sub.name == command.default_sub and len(command.subs) == 1):
|
|
240
|
+
return command.name
|
|
241
|
+
return f"{command.name} {sub.name}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _invocation(command: Command, sub: Sub) -> str:
|
|
245
|
+
return f"plex-axi {_label(command, sub)}"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_positionals(sub: Sub, command: Command, values: list) -> None:
|
|
249
|
+
required = [a for a in sub.args if a.startswith("<")]
|
|
250
|
+
if len(values) < len(required):
|
|
251
|
+
missing = required[len(values)]
|
|
252
|
+
raise UsageError(
|
|
253
|
+
f"{_invocation(command, sub)} needs {missing}",
|
|
254
|
+
help_lines=[f"Run `{_invocation(command, sub)} {' '.join(sub.args)}`"],
|
|
255
|
+
code="MISSING_ARGUMENT",
|
|
256
|
+
)
|
|
257
|
+
if len(values) > len(sub.args):
|
|
258
|
+
extra = values[len(sub.args)]
|
|
259
|
+
raise UsageError(
|
|
260
|
+
f"unexpected argument {extra!r} for `{_label(command, sub)}`",
|
|
261
|
+
help_lines=[f"Run `{_invocation(command, sub)} {' '.join(sub.args)}`"],
|
|
262
|
+
code="UNEXPECTED_ARGUMENT",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _unknown_flag(name: str, sub: Sub, command: Command):
|
|
267
|
+
replacement = RENAMED.get(name)
|
|
268
|
+
valid = [flag.name for flag in sub.flags]
|
|
269
|
+
if replacement and replacement in valid:
|
|
270
|
+
return UsageError(
|
|
271
|
+
f"unknown flag {name} for `{_label(command, sub)}`; use {replacement} instead",
|
|
272
|
+
help_lines=[f"Run `{_invocation(command, sub)} {replacement} <value>`"],
|
|
273
|
+
code="UNKNOWN_FLAG",
|
|
274
|
+
)
|
|
275
|
+
listing = ", ".join(valid) if valid else "(none)"
|
|
276
|
+
return UsageError(
|
|
277
|
+
f"unknown flag {name} for `{_label(command, sub)}`",
|
|
278
|
+
help_lines=[
|
|
279
|
+
f"valid flags for `{_label(command, sub)}`: {listing} (--help always allowed)",
|
|
280
|
+
f"Run `plex-axi {command.name} --help` for the full reference",
|
|
281
|
+
],
|
|
282
|
+
code="UNKNOWN_FLAG",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --------------------------------------------------------------------- help
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def render_command_help(command: Command) -> str:
|
|
290
|
+
"""Render one command's concise, complete reference."""
|
|
291
|
+
lines = [command.usage or f"usage: plex-axi {command.name} <subcommand> [flags]"]
|
|
292
|
+
lines.append(f"description: {command.summary}")
|
|
293
|
+
|
|
294
|
+
if command.subs and not (len(command.subs) == 1 and command.subs[0].name == command.name):
|
|
295
|
+
signatures = [sub.signature() for sub in command.subs]
|
|
296
|
+
lines.append(f"subcommands[{len(signatures)}]:")
|
|
297
|
+
lines.append(" " + ", ".join(signatures))
|
|
298
|
+
|
|
299
|
+
for sub in command.subs:
|
|
300
|
+
label = sub.name
|
|
301
|
+
rendered = [flag.render() for flag in sub.flags]
|
|
302
|
+
lines.append(f"flags{{{label}}}:")
|
|
303
|
+
lines.append(" " + (", ".join(rendered) if rendered else "(none)"))
|
|
304
|
+
|
|
305
|
+
for note in command.notes:
|
|
306
|
+
lines.append("note:")
|
|
307
|
+
lines.append(f" {note}")
|
|
308
|
+
|
|
309
|
+
if command.examples:
|
|
310
|
+
lines.append("examples:")
|
|
311
|
+
lines.extend(f" {example}" for example in command.examples)
|
|
312
|
+
return "\n".join(lines)
|