stashapp-cli 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.
Files changed (41) hide show
  1. stashapp_cli/__init__.py +2 -0
  2. stashapp_cli/cli.py +149 -0
  3. stashapp_cli/commands/__init__.py +39 -0
  4. stashapp_cli/commands/_common.py +440 -0
  5. stashapp_cli/commands/backup.py +70 -0
  6. stashapp_cli/commands/generate.py +88 -0
  7. stashapp_cli/commands/graphql.py +212 -0
  8. stashapp_cli/commands/group.py +137 -0
  9. stashapp_cli/commands/introspect.py +35 -0
  10. stashapp_cli/commands/job.py +122 -0
  11. stashapp_cli/commands/performer.py +368 -0
  12. stashapp_cli/commands/scan.py +92 -0
  13. stashapp_cli/commands/scene.py +405 -0
  14. stashapp_cli/commands/scrape.py +133 -0
  15. stashapp_cli/commands/stashbox.py +173 -0
  16. stashapp_cli/commands/studio.py +227 -0
  17. stashapp_cli/commands/tag.py +694 -0
  18. stashapp_cli/core.py +165 -0
  19. stashapp_cli/images.py +72 -0
  20. stashapp_cli/introspect.py +343 -0
  21. stashapp_cli/py.typed +0 -0
  22. stashapp_cli/queries/__init__.py +40 -0
  23. stashapp_cli/queries/_get.py +27 -0
  24. stashapp_cli/queries/_image_flags.py +30 -0
  25. stashapp_cli/queries/backup.py +20 -0
  26. stashapp_cli/queries/group.py +117 -0
  27. stashapp_cli/queries/job.py +29 -0
  28. stashapp_cli/queries/metadata.py +42 -0
  29. stashapp_cli/queries/performer.py +179 -0
  30. stashapp_cli/queries/scene.py +139 -0
  31. stashapp_cli/queries/scrape.py +183 -0
  32. stashapp_cli/queries/sources.py +76 -0
  33. stashapp_cli/queries/stashbox.py +67 -0
  34. stashapp_cli/queries/studio.py +145 -0
  35. stashapp_cli/queries/tag.py +244 -0
  36. stashapp_cli/sources.py +116 -0
  37. stashapp_cli-0.1.0.dist-info/METADATA +208 -0
  38. stashapp_cli-0.1.0.dist-info/RECORD +41 -0
  39. stashapp_cli-0.1.0.dist-info/WHEEL +4 -0
  40. stashapp_cli-0.1.0.dist-info/entry_points.txt +3 -0
  41. stashapp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from stashapp-cli!"
stashapp_cli/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """stashapp-cli: inspect and modify Stash's GraphQL entities directly.
2
+
3
+ Layers: a raw `graphql` escape hatch, generic per-resource `find`/`get`/
4
+ `create`/`update` commands, and named platform-quirk commands (`tag
5
+ find-or-create`, `scrape group-url`/`scene-url`). Every mutating command is
6
+ gated by the global `--apply` flag (dry-run by default); destructive commands
7
+ (`performer merge`, and raw GraphQL *Destroy/*Delete/*Merge, ...) additionally
8
+ require `--force`. Results print as JSON on stdout; human-facing status and
9
+ errors print to stderr.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.metadata
16
+ import sys
17
+ from collections.abc import Iterator
18
+ from pathlib import Path
19
+
20
+ from . import commands, core
21
+ from .commands._common import add_examples, positive_seconds
22
+
23
+
24
+ def _package_version() -> str:
25
+ """Return installed metadata, with a source-tree fallback for development."""
26
+ try:
27
+ return importlib.metadata.version("stashapp-cli")
28
+ except importlib.metadata.PackageNotFoundError:
29
+ pyproject = Path(__file__).parents[2] / "pyproject.toml"
30
+ try:
31
+ import tomllib
32
+
33
+ return tomllib.loads(pyproject.read_text())["project"]["version"]
34
+ except (OSError, KeyError, tomllib.TOMLDecodeError):
35
+ return "unknown"
36
+
37
+
38
+ def _emit_error(error: core.CliError, json_errors: bool) -> None:
39
+ if json_errors:
40
+ core.output(
41
+ {"error": {"code": error.code, "message": str(error), **error.details}}
42
+ )
43
+ else:
44
+ print(str(error), file=sys.stderr)
45
+
46
+
47
+ def _normalize_apply(argv: list[str]) -> list[str]:
48
+ """Normalize global arguments that argparse cannot otherwise disambiguate."""
49
+ normalized = list(argv)
50
+ for index, value in enumerate(normalized[:-1]):
51
+ if value == "--timeout" and normalized[index + 1].lower() in {
52
+ "-inf",
53
+ "-infinity",
54
+ }:
55
+ normalized[index : index + 2] = [f"--timeout={normalized[index + 1]}"]
56
+ break
57
+ if normalized.count("--apply") != 1:
58
+ return normalized
59
+ index = normalized.index("--apply")
60
+ if index == 0:
61
+ return normalized
62
+ return ["--apply", *normalized[:index], *normalized[index + 1 :]]
63
+
64
+
65
+ # --- entrypoint ------------------------------------------------------------
66
+
67
+
68
+ def iter_leaf_commands(
69
+ parser: argparse.ArgumentParser, path: tuple[str, ...] = ()
70
+ ) -> Iterator[tuple[tuple[str, ...], argparse.ArgumentParser]]:
71
+ """Yield `(path, parser)` for every leaf command below `parser`."""
72
+ subparsers = [
73
+ action
74
+ for action in parser._actions
75
+ if isinstance(action, argparse._SubParsersAction)
76
+ ]
77
+ if not subparsers:
78
+ yield path, parser
79
+ return
80
+ for action in subparsers:
81
+ for name, child in action.choices.items():
82
+ yield from iter_leaf_commands(child, (*path, name))
83
+
84
+
85
+ def build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(prog="stashapp-cli", description=__doc__)
87
+ parser.add_argument(
88
+ "--apply",
89
+ action="store_true",
90
+ help=(
91
+ "Perform mutating commands instead of reporting a dry run "
92
+ "(default: dry-run; inert on read-only commands)."
93
+ ),
94
+ )
95
+ parser.add_argument(
96
+ "--json-errors",
97
+ action="store_true",
98
+ help="Write expected failures as one JSON error envelope on stdout.",
99
+ )
100
+ parser.add_argument(
101
+ "--timeout",
102
+ type=positive_seconds,
103
+ default=core.DEFAULT_TIMEOUT,
104
+ metavar="SECONDS",
105
+ help="Maximum seconds for each Stash request (default: 30).",
106
+ )
107
+ parser.add_argument("--version", action="version", version=_package_version())
108
+ subparsers = parser.add_subparsers(dest="command", required=True)
109
+ for module in commands.COMMAND_MODULES:
110
+ module.register(subparsers)
111
+ add_examples(
112
+ parser,
113
+ "export STASH_URL=http://localhost:9999; export STASH_API_KEY",
114
+ "stashapp-cli studio find --name ExampleStudio",
115
+ 'stashapp-cli group create --input \'{"name": "Film"}\'',
116
+ "stashapp-cli group create --input @group.json --apply",
117
+ "stashapp-cli --json-errors --timeout 5 scene get 1",
118
+ )
119
+ parser.epilog = (parser.epilog or "") + (
120
+ "\n\nExit statuses: 0 success; 1 runtime or GraphQL failure; "
121
+ "2 usage or invalid input (including destructive_mutation: a destructive\n"
122
+ "command sent with --apply but without --force); "
123
+ "3 Stash endpoint returned HTTP 404; "
124
+ "4 missing credential or authentication failure."
125
+ )
126
+ return parser
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> None:
130
+ parser = build_parser()
131
+ args = parser.parse_args(
132
+ _normalize_apply(list(argv))
133
+ if argv is not None
134
+ else _normalize_apply(sys.argv[1:])
135
+ )
136
+ try:
137
+ with core.request_timeout(args.timeout):
138
+ result = args.func(args)
139
+ except core.CliError as error:
140
+ _emit_error(error, args.json_errors)
141
+ sys.exit(error.exit_code)
142
+ except Exception as error: # noqa: BLE001
143
+ _emit_error(core.CliError(f"Unexpected error: {error}", 1), args.json_errors)
144
+ sys.exit(1)
145
+ core.output(result)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1,39 @@
1
+ """stashapp-cli command modules, one per noun.
2
+
3
+ Each module exposes `register(subparsers)`. `cli.build_parser` registers them
4
+ in the order of `COMMAND_MODULES`, which is also the order in `--help`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import (
10
+ backup,
11
+ generate,
12
+ graphql,
13
+ group,
14
+ introspect,
15
+ job,
16
+ performer,
17
+ scan,
18
+ scene,
19
+ scrape,
20
+ stashbox,
21
+ studio,
22
+ tag,
23
+ )
24
+
25
+ COMMAND_MODULES = (
26
+ studio,
27
+ tag,
28
+ group,
29
+ scene,
30
+ performer,
31
+ scrape,
32
+ scan,
33
+ generate,
34
+ job,
35
+ backup,
36
+ stashbox,
37
+ introspect,
38
+ graphql,
39
+ )
@@ -0,0 +1,440 @@
1
+ """Helpers shared by the command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import sys
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+
14
+ from .. import core, images
15
+
16
+
17
+ class CommandKind(StrEnum):
18
+ """Whether a leaf command reads, writes, or makes an irreversible change."""
19
+
20
+ READ = "read"
21
+ WRITE = "write"
22
+ DESTRUCTIVE = "destructive"
23
+
24
+
25
+ _KIND_NOTES = {
26
+ CommandKind.READ: "Read command: never changes Stash; --apply has no effect.",
27
+ CommandKind.WRITE: "Write command: a dry run unless --apply is given.",
28
+ CommandKind.DESTRUCTIVE: (
29
+ "Destructive command: a dry run unless --apply is given. An\n"
30
+ "irreversible change also needs --force; without it the command exits 2\n"
31
+ "with code destructive_mutation."
32
+ ),
33
+ }
34
+
35
+ FORCE_HELP = "Required with --apply to send an irreversible change; there is no undo."
36
+
37
+
38
+ def add_examples(parser: argparse.ArgumentParser, *examples: str) -> None:
39
+ parser.epilog = "Examples:\n " + "\n ".join(examples)
40
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
41
+
42
+
43
+ def add_leaf(
44
+ subparsers: argparse._SubParsersAction,
45
+ name: str,
46
+ *,
47
+ kind: CommandKind,
48
+ func: Callable[[argparse.Namespace], object],
49
+ help: str,
50
+ examples: tuple[str, ...],
51
+ ) -> argparse.ArgumentParser:
52
+ """Register a leaf command with its kind, examples, and kind note.
53
+
54
+ Destructive commands get `--force`; no other kind does.
55
+ """
56
+ parser = subparsers.add_parser(name, help=help)
57
+ parser.set_defaults(func=func, kind=kind)
58
+ add_examples(parser, *examples)
59
+ parser.epilog = f"{parser.epilog}\n\n{_KIND_NOTES[kind]}"
60
+ if kind is CommandKind.DESTRUCTIVE:
61
+ parser.add_argument("--force", action="store_true", help=FORCE_HELP)
62
+ return parser
63
+
64
+
65
+ def object_id(value: str, noun: str) -> str:
66
+ """Return `value`, or raise usage_error unless it is ASCII digits.
67
+
68
+ Checked in the command, not with an argparse `type=`, so that
69
+ `--json-errors` reports the `usage_error` code.
70
+ """
71
+ if not (value.isascii() and value.isdigit()):
72
+ raise core.CliError(
73
+ f"{noun.capitalize()} ID must be a non-negative integer, not {value!r}.",
74
+ 2,
75
+ "usage_error",
76
+ )
77
+ return value
78
+
79
+
80
+ def add_get_command(
81
+ subparsers: argparse._SubParsersAction,
82
+ noun: str,
83
+ get: Callable[[str], dict | None],
84
+ *,
85
+ example_id: int,
86
+ ) -> argparse.ArgumentParser:
87
+ """Register `<noun> get ID`, a read that prints one object or `null`."""
88
+ leaf = add_leaf(
89
+ subparsers,
90
+ "get",
91
+ kind=CommandKind.READ,
92
+ func=lambda args: get(object_id(args.id, noun)),
93
+ help=f"Fetch one {noun} by ID, or null if there is none.",
94
+ examples=(f"stashapp-cli {noun} get {example_id}",),
95
+ )
96
+ leaf.description = (
97
+ f"Print the {noun} with this ID as one JSON object, with the fields that\n"
98
+ f"`{noun} find` returns. Print null when no {noun} has this ID."
99
+ )
100
+ leaf.add_argument(
101
+ "id", metavar="ID", help=f"{noun.capitalize()} ID: a non-negative integer."
102
+ )
103
+ return leaf
104
+
105
+
106
+ def read_text_arg(value: str) -> str:
107
+ """Read raw text from a literal value, `@file`, or `@-` (stdin)."""
108
+ if value.startswith("@"):
109
+ path = value[1:]
110
+ if path == "-":
111
+ return sys.stdin.read()
112
+ try:
113
+ return Path(path).read_text()
114
+ except OSError as error:
115
+ raise core.CliError(
116
+ f"Could not read {path}: {error}", 2, "usage_error"
117
+ ) from error
118
+ return value
119
+
120
+
121
+ def read_json_arg(value: str) -> dict:
122
+ """Read JSON from a literal value, `@file`, or `@-` (stdin)."""
123
+ try:
124
+ return json.loads(read_text_arg(value))
125
+ except json.JSONDecodeError as error:
126
+ raise core.CliError(
127
+ f"Invalid JSON argument: {error}", 2, "usage_error"
128
+ ) from error
129
+
130
+
131
+ def require_force(args: argparse.Namespace, matched: list[str]) -> None:
132
+ """Refuse an applied destructive change unless `--force` was given.
133
+
134
+ `matched` names what makes the change destructive; empty means it is not.
135
+ Call after the dry-run branch so `--force` without `--apply` stays a dry run.
136
+ """
137
+ if args.apply and matched and not args.force:
138
+ raise core.CliError(
139
+ "Refusing to send destructive mutation (matched: "
140
+ + ", ".join(matched)
141
+ + "); there is no undo. Re-run with --apply --force if this is intended.",
142
+ 2,
143
+ "destructive_mutation",
144
+ )
145
+
146
+
147
+ def add_update_target(parser: argparse.ArgumentParser, input_type: str) -> None:
148
+ """Add the optional positional ID and optional `--input` of an update."""
149
+ parser.add_argument(
150
+ "id",
151
+ nargs="?",
152
+ default=None,
153
+ metavar="ID",
154
+ help="Object to update; fills or must match the id in --input.",
155
+ )
156
+ parser.add_argument(
157
+ "--input",
158
+ default=None,
159
+ help=f"{input_type} as JSON, @file, or @- (optional when ID is given).",
160
+ )
161
+
162
+
163
+ def resolve_update_input(
164
+ args: argparse.Namespace, overrides: dict | None = None
165
+ ) -> dict:
166
+ """Merge the positional ID into `--input` and check that there is a change.
167
+
168
+ `overrides` holds fields that options set; they replace the same fields in
169
+ `--input` and count as a change.
170
+ """
171
+ if args.id is None:
172
+ if args.input is None:
173
+ raise core.CliError("Pass an ID or --input.", 2, "usage_error")
174
+ input_data = read_json_arg(args.input)
175
+ if overrides and isinstance(input_data, dict):
176
+ input_data.update(overrides)
177
+ return input_data
178
+
179
+ input_data = read_json_arg(args.input) if args.input is not None else {}
180
+ if not isinstance(input_data, dict):
181
+ raise core.CliError(
182
+ "--input must be a JSON object when ID is given.", 2, "usage_error"
183
+ )
184
+ input_data.update(overrides or {})
185
+ if "id" in input_data:
186
+ if str(input_data["id"]) != args.id:
187
+ raise core.CliError(
188
+ f"ID {args.id} does not match id {input_data['id']} in --input.",
189
+ 2,
190
+ "usage_error",
191
+ )
192
+ else:
193
+ input_data["id"] = args.id
194
+ if input_data.keys() == {"id"}:
195
+ raise core.CliError(
196
+ f"Nothing to update for ID {args.id}; pass the fields to change in "
197
+ "--input, or an --add-* or --remove-* option.",
198
+ 2,
199
+ "usage_error",
200
+ )
201
+ return input_data
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class ListField:
206
+ """A list field that `--add-<option>` and `--remove-<option>` change in bulk."""
207
+
208
+ option: str # "tag-id", "url", "performer-id", "alias"
209
+ field: str # "tag_ids", "urls", "performer_ids", "alias_list"
210
+ key: str # "ids" (BulkUpdateIds) or "values" (BulkUpdateStrings)
211
+ metavar: str # "TAG_ID", "URL", "PERFORMER_ID", "ALIAS"
212
+
213
+ @property
214
+ def dests(self) -> tuple[str, str]:
215
+ name = self.option.replace("-", "_")
216
+ return f"add_{name}", f"remove_{name}"
217
+
218
+
219
+ TAG_IDS = ListField("tag-id", "tag_ids", "ids", "TAG_ID")
220
+ URLS = ListField("url", "urls", "values", "URL")
221
+ PERFORMER_IDS = ListField("performer-id", "performer_ids", "ids", "PERFORMER_ID")
222
+ ALIASES = ListField("alias", "alias_list", "values", "ALIAS")
223
+ STUDIO_ALIASES = ListField("alias", "aliases", "values", "ALIAS")
224
+ TAG_ALIASES = ListField("alias", "aliases", "values", "ALIAS")
225
+ PARENT_IDS = ListField("parent-id", "parent_ids", "ids", "PARENT_ID")
226
+ CHILD_IDS = ListField("child-id", "child_ids", "ids", "CHILD_ID")
227
+
228
+ LIST_OPTION_NOTE = "Repeatable; needs ID and cannot be combined with --input."
229
+
230
+
231
+ def add_list_options(
232
+ parser: argparse.ArgumentParser, fields: tuple[ListField, ...]
233
+ ) -> None:
234
+ """Register repeatable `--add-<option>` and `--remove-<option>` for `fields`."""
235
+ for list_field in fields:
236
+ add_dest, remove_dest = list_field.dests
237
+ for verb, dest in (("add", add_dest), ("remove", remove_dest)):
238
+ parser.add_argument(
239
+ f"--{verb}-{list_field.option}",
240
+ dest=dest,
241
+ action="append",
242
+ default=None,
243
+ metavar=list_field.metavar,
244
+ help=f"{verb.capitalize()} one {list_field.field} value. "
245
+ + LIST_OPTION_NOTE,
246
+ )
247
+
248
+
249
+ def unique(values: list[str]) -> list[str]:
250
+ """`values` without repeats, in the order first given."""
251
+ return list(dict.fromkeys(values))
252
+
253
+
254
+ def bulk_update_input(
255
+ args: argparse.Namespace, fields: tuple[ListField, ...]
256
+ ) -> dict | None:
257
+ """The `BulkUpdate*` input for the list options in `fields`, or `None`."""
258
+ input_data: dict = {"ids": [args.id]}
259
+ for list_field in fields:
260
+ add_dest, remove_dest = list_field.dests
261
+ add, remove = getattr(args, add_dest), getattr(args, remove_dest)
262
+ if add and remove:
263
+ raise core.CliError(
264
+ f"--add-{list_field.option} and --remove-{list_field.option} "
265
+ "cannot be combined; run two commands.",
266
+ 2,
267
+ "usage_error",
268
+ )
269
+ if add or remove:
270
+ input_data[list_field.field] = {
271
+ list_field.key: unique(add or remove),
272
+ "mode": "ADD" if add else "REMOVE",
273
+ }
274
+ return input_data if len(input_data) > 1 else None
275
+
276
+
277
+ def require_list_target(args: argparse.Namespace) -> None:
278
+ """Check that add and remove options have the positional ID and no `--input`."""
279
+ if args.id is None:
280
+ raise core.CliError(
281
+ "The --add-* and --remove-* options need the ID argument.",
282
+ 2,
283
+ "usage_error",
284
+ )
285
+ if args.input is not None:
286
+ raise core.CliError(
287
+ "The --add-* and --remove-* options cannot be combined with --input; "
288
+ "run two commands.",
289
+ 2,
290
+ "usage_error",
291
+ )
292
+
293
+
294
+ def mutation_result(operation: str, apply: bool, input_data: dict, perform):
295
+ """Dry-run report of `input_data`, or perform the write and report its id."""
296
+ if not apply:
297
+ # Image data URIs are left out of the report; `perform` sends them as given.
298
+ report = images.replace_images(input_data, None)
299
+ return {"applied": False, "operation": operation, "input": report}
300
+ return {"applied": True, "operation": operation, "id": perform()}
301
+
302
+
303
+ def job_result(operation: str, apply: bool, input_data: dict, perform):
304
+ """Dry-run report of `input_data`, or start the job and report its job id."""
305
+ if not apply:
306
+ return {"applied": False, "operation": operation, "input": input_data}
307
+ return {"applied": True, "operation": operation, "job_id": perform()}
308
+
309
+
310
+ def read_input_object(value: str | None) -> dict:
311
+ """Read an optional `--input` that must be a JSON object; `{}` when absent."""
312
+ if value is None:
313
+ return {}
314
+ input_data = read_json_arg(value)
315
+ if not isinstance(input_data, dict):
316
+ raise core.CliError("--input must be a JSON object.", 2, "usage_error")
317
+ return input_data
318
+
319
+
320
+ def positive_seconds(value: str) -> float:
321
+ """Argparse type for a positive, finite number of seconds."""
322
+ # argparse prefixes the option name, for example "argument --timeout: ...".
323
+ message = "must be a positive number of seconds"
324
+ try:
325
+ seconds = float(value)
326
+ except ValueError as error:
327
+ raise argparse.ArgumentTypeError(message) from error
328
+ if not math.isfinite(seconds) or seconds <= 0:
329
+ raise argparse.ArgumentTypeError(message)
330
+ return seconds
331
+
332
+
333
+ def positive_int(value: str) -> int:
334
+ """Argparse type for an integer of 1 or more."""
335
+ message = "must be an integer of 1 or more"
336
+ try:
337
+ number = int(value)
338
+ except ValueError as error:
339
+ raise argparse.ArgumentTypeError(message) from error
340
+ if number < 1:
341
+ raise argparse.ArgumentTypeError(message)
342
+ return number
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class FindSpec:
347
+ """How one `find` command differs from the others."""
348
+
349
+ result_key: str # "studios", "groups", "scenes", "performers", "tags"
350
+ name_field: str # the criterion that --name sets: "name", or "title" for scenes
351
+ match_aliases: bool # whether --name also matches the aliases criterion
352
+ sort: str # "name", or "path" for scenes
353
+
354
+
355
+ # A filter level may hold only one of these, so options cannot be merged next
356
+ # to one the caller gave.
357
+ _SUB_FILTERS = ("AND", "OR", "NOT")
358
+
359
+
360
+ def add_find_options(parser: argparse.ArgumentParser, spec: FindSpec) -> None:
361
+ """Add `--name`, `--filter-json`, `--page`, and `--per-page`, and note the result."""
362
+ if spec.match_aliases:
363
+ matches = f"the {spec.name_field} or any alias"
364
+ elif spec.name_field == "name":
365
+ matches = "the name only, not aliases"
366
+ else:
367
+ matches = f"the {spec.name_field}"
368
+ parser.add_argument(
369
+ "--name",
370
+ default=None,
371
+ help=f"Match {matches} exactly, ignoring case.",
372
+ )
373
+ parser.add_argument(
374
+ "--filter-json",
375
+ default=None,
376
+ help="Filter object as JSON, @file, or @- (default: match everything).",
377
+ )
378
+ parser.add_argument("--page", type=positive_int, default=1, help="Default: 1.")
379
+ parser.add_argument(
380
+ "--per-page", type=positive_int, default=50, help="Default: 50."
381
+ )
382
+ parser.epilog = f"{parser.epilog}\n\n" + (
383
+ f'Prints {{"count": N, "{spec.result_key}": [...]}}: the number of matches '
384
+ f"and one page\nof them, sorted by {spec.sort} ascending. Without --name or "
385
+ "--filter-json, every\nobject matches. --name is added to --filter-json, "
386
+ "so a match meets both. A\nfilter that already sets what --name sets, or "
387
+ "that has a top-level AND, OR,\nor NOT while --name is given, is a "
388
+ "usage_error."
389
+ )
390
+
391
+
392
+ def find_filter(
393
+ args: argparse.Namespace, spec: FindSpec, extra: dict | None = None
394
+ ) -> dict:
395
+ """The filter to send: `--filter-json` with the `--name` and `extra` criteria.
396
+
397
+ `extra` holds criteria that other options set, such as `studios`. The name
398
+ criteria go under `AND`, because a level cannot hold both its own `OR` (the
399
+ alias match) and the caller's criteria combined by AND.
400
+ """
401
+ user = read_json_arg(args.filter_json) if args.filter_json is not None else {}
402
+ if not isinstance(user, dict):
403
+ raise core.CliError("--filter-json must be a JSON object.", 2, "usage_error")
404
+ if args.name == "":
405
+ raise core.CliError("--name needs a non-empty value.", 2, "usage_error")
406
+
407
+ extra = extra or {}
408
+ set_fields = list(extra)
409
+ if args.name is not None:
410
+ set_fields.append(spec.name_field)
411
+ if spec.match_aliases:
412
+ set_fields.append("aliases")
413
+ if conflicts := [field for field in set_fields if field in user]:
414
+ raise core.CliError(
415
+ f"--filter-json already has {', '.join(conflicts)}, which an option "
416
+ "also sets. Put the criterion in --filter-json or in the option, "
417
+ "not both.",
418
+ 2,
419
+ "usage_error",
420
+ )
421
+ if not set_fields:
422
+ return user
423
+ if sub_filters := [key for key in _SUB_FILTERS if key in user]:
424
+ raise core.CliError(
425
+ f"--filter-json has a top-level {', '.join(sub_filters)}, so the "
426
+ "options cannot be added to it. Put the name, studio, or code "
427
+ "criterion in the filter instead.",
428
+ 2,
429
+ "usage_error",
430
+ )
431
+
432
+ merged = {**user, **extra}
433
+ if args.name is not None:
434
+ name_filter: dict = {
435
+ spec.name_field: {"value": args.name, "modifier": "EQUALS"}
436
+ }
437
+ if spec.match_aliases:
438
+ name_filter["OR"] = {"aliases": {"value": args.name, "modifier": "EQUALS"}}
439
+ merged["AND"] = name_filter
440
+ return merged