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 ADDED
@@ -0,0 +1,6 @@
1
+ """plex-axi: an Agent eXperience Interface for structured music search in Plex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0" # x-release-please-version
6
+ __all__ = ["__version__"]
plex_axi/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m plex_axi`` to behave exactly like the installed console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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)