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.
- stashapp_cli/__init__.py +2 -0
- stashapp_cli/cli.py +149 -0
- stashapp_cli/commands/__init__.py +39 -0
- stashapp_cli/commands/_common.py +440 -0
- stashapp_cli/commands/backup.py +70 -0
- stashapp_cli/commands/generate.py +88 -0
- stashapp_cli/commands/graphql.py +212 -0
- stashapp_cli/commands/group.py +137 -0
- stashapp_cli/commands/introspect.py +35 -0
- stashapp_cli/commands/job.py +122 -0
- stashapp_cli/commands/performer.py +368 -0
- stashapp_cli/commands/scan.py +92 -0
- stashapp_cli/commands/scene.py +405 -0
- stashapp_cli/commands/scrape.py +133 -0
- stashapp_cli/commands/stashbox.py +173 -0
- stashapp_cli/commands/studio.py +227 -0
- stashapp_cli/commands/tag.py +694 -0
- stashapp_cli/core.py +165 -0
- stashapp_cli/images.py +72 -0
- stashapp_cli/introspect.py +343 -0
- stashapp_cli/py.typed +0 -0
- stashapp_cli/queries/__init__.py +40 -0
- stashapp_cli/queries/_get.py +27 -0
- stashapp_cli/queries/_image_flags.py +30 -0
- stashapp_cli/queries/backup.py +20 -0
- stashapp_cli/queries/group.py +117 -0
- stashapp_cli/queries/job.py +29 -0
- stashapp_cli/queries/metadata.py +42 -0
- stashapp_cli/queries/performer.py +179 -0
- stashapp_cli/queries/scene.py +139 -0
- stashapp_cli/queries/scrape.py +183 -0
- stashapp_cli/queries/sources.py +76 -0
- stashapp_cli/queries/stashbox.py +67 -0
- stashapp_cli/queries/studio.py +145 -0
- stashapp_cli/queries/tag.py +244 -0
- stashapp_cli/sources.py +116 -0
- stashapp_cli-0.1.0.dist-info/METADATA +208 -0
- stashapp_cli-0.1.0.dist-info/RECORD +41 -0
- stashapp_cli-0.1.0.dist-info/WHEEL +4 -0
- stashapp_cli-0.1.0.dist-info/entry_points.txt +3 -0
- stashapp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
stashapp_cli/__init__.py
ADDED
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
|