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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""backup command: back up the Stash database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import core, queries
|
|
8
|
+
from ._common import CommandKind, add_leaf
|
|
9
|
+
|
|
10
|
+
_SERVER_LOG_MESSAGE = (
|
|
11
|
+
"Stash wrote the backup; its path is only in the Stash server log."
|
|
12
|
+
)
|
|
13
|
+
_DOWNLOAD_MESSAGE = (
|
|
14
|
+
"Stash wrote the backup to a temporary file; download it from download_url."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_backup(args: argparse.Namespace) -> object:
|
|
19
|
+
input_data = {"download": args.download}
|
|
20
|
+
if not args.apply:
|
|
21
|
+
return {"applied": False, "operation": "backupDatabase", "input": input_data}
|
|
22
|
+
try:
|
|
23
|
+
download_url = queries.backup.backup_database(args.download)
|
|
24
|
+
except core.CliError as error:
|
|
25
|
+
if error.code != "timeout":
|
|
26
|
+
raise
|
|
27
|
+
raise core.CliError(
|
|
28
|
+
f"{error} The backup may still finish on the Stash server; check the "
|
|
29
|
+
"server log, or re-run with a larger global --timeout "
|
|
30
|
+
"(for example --timeout 300).",
|
|
31
|
+
error.exit_code,
|
|
32
|
+
error.code,
|
|
33
|
+
error.details,
|
|
34
|
+
) from error
|
|
35
|
+
return {
|
|
36
|
+
"applied": True,
|
|
37
|
+
"operation": "backupDatabase",
|
|
38
|
+
"download": args.download,
|
|
39
|
+
"download_url": download_url if args.download else None,
|
|
40
|
+
"message": _DOWNLOAD_MESSAGE if args.download else _SERVER_LOG_MESSAGE,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
45
|
+
parser = add_leaf(
|
|
46
|
+
subparsers,
|
|
47
|
+
"backup",
|
|
48
|
+
kind=CommandKind.WRITE,
|
|
49
|
+
func=cmd_backup,
|
|
50
|
+
help="Back up the Stash database (dry-run by default; see global --apply).",
|
|
51
|
+
examples=(
|
|
52
|
+
"stashapp-cli backup",
|
|
53
|
+
"stashapp-cli backup --apply",
|
|
54
|
+
"stashapp-cli --timeout 300 backup --apply",
|
|
55
|
+
"stashapp-cli backup --download --apply",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--download",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Return a URL to download the backup instead of keeping it on the server.",
|
|
62
|
+
)
|
|
63
|
+
parser.epilog = f"{parser.epilog}" + (
|
|
64
|
+
"\n\nWithout --download, Stash keeps the backup in its backup directory on "
|
|
65
|
+
"the\nserver, and its path appears only in the Stash server log. With "
|
|
66
|
+
"--download,\nthe backup is a temporary file to download from "
|
|
67
|
+
"download_url; it is not\nkept in the backup directory. Stash finishes "
|
|
68
|
+
"the backup before it answers,\nso a large database can need a larger "
|
|
69
|
+
"global --timeout."
|
|
70
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""generate commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import queries
|
|
8
|
+
from ._common import CommandKind, add_leaf, job_result, read_input_object
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_input(args: argparse.Namespace) -> dict:
|
|
12
|
+
"""Build a GenerateMetadataInput: `--input`, then the options over their fields."""
|
|
13
|
+
input_data = read_input_object(args.input)
|
|
14
|
+
if args.phashes:
|
|
15
|
+
input_data["phashes"] = True
|
|
16
|
+
if args.covers:
|
|
17
|
+
input_data["covers"] = True
|
|
18
|
+
if args.scene_ids is not None:
|
|
19
|
+
input_data["sceneIDs"] = args.scene_ids
|
|
20
|
+
if args.paths is not None:
|
|
21
|
+
input_data["paths"] = args.paths
|
|
22
|
+
return input_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_generate_run(args: argparse.Namespace) -> object:
|
|
26
|
+
input_data = generate_input(args)
|
|
27
|
+
return job_result(
|
|
28
|
+
"metadataGenerate",
|
|
29
|
+
args.apply,
|
|
30
|
+
input_data,
|
|
31
|
+
lambda: queries.metadata.start_generate(input_data),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
36
|
+
generate = subparsers.add_parser(
|
|
37
|
+
"generate", help="Start Stash generate jobs (phashes, covers, ...)."
|
|
38
|
+
)
|
|
39
|
+
generate_sub = generate.add_subparsers(dest="generate_command", required=True)
|
|
40
|
+
|
|
41
|
+
run = add_leaf(
|
|
42
|
+
generate_sub,
|
|
43
|
+
"run",
|
|
44
|
+
kind=CommandKind.WRITE,
|
|
45
|
+
func=cmd_generate_run,
|
|
46
|
+
help="Start a generate job and print its job id.",
|
|
47
|
+
examples=(
|
|
48
|
+
"stashapp-cli generate run --phashes --scene-id 812 --scene-id 813",
|
|
49
|
+
"stashapp-cli generate run --covers --path /media/new --apply",
|
|
50
|
+
"stashapp-cli generate run --scene-id 5 --input '{\"sprites\": true}'",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
run.description = (
|
|
54
|
+
"Start a Stash generate job (metadataGenerate) and print its job_id.\n"
|
|
55
|
+
"Wait for it with `stashapp-cli job wait JOB_ID`.\n"
|
|
56
|
+
"\n"
|
|
57
|
+
"Generate options that are left out are off. With no --scene-id and no\n"
|
|
58
|
+
"--path (and none in --input), Stash generates for the whole library."
|
|
59
|
+
)
|
|
60
|
+
run.add_argument(
|
|
61
|
+
"--phashes", action="store_true", help="Generate phashes (sets phashes)."
|
|
62
|
+
)
|
|
63
|
+
run.add_argument(
|
|
64
|
+
"--covers", action="store_true", help="Generate covers (sets covers)."
|
|
65
|
+
)
|
|
66
|
+
run.add_argument(
|
|
67
|
+
"--scene-id",
|
|
68
|
+
dest="scene_ids",
|
|
69
|
+
action="append",
|
|
70
|
+
default=None,
|
|
71
|
+
metavar="ID",
|
|
72
|
+
help="Scene to generate for; repeatable. Replaces sceneIDs in --input.",
|
|
73
|
+
)
|
|
74
|
+
run.add_argument(
|
|
75
|
+
"--path",
|
|
76
|
+
dest="paths",
|
|
77
|
+
action="append",
|
|
78
|
+
default=None,
|
|
79
|
+
metavar="PATH",
|
|
80
|
+
help="Path to generate for; repeatable. Replaces paths in --input.",
|
|
81
|
+
)
|
|
82
|
+
run.add_argument(
|
|
83
|
+
"--input",
|
|
84
|
+
default=None,
|
|
85
|
+
help=(
|
|
86
|
+
"GenerateMetadataInput as JSON, @file, or @-; options override its fields."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""graphql command: send a raw GraphQL document (escape hatch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import core
|
|
8
|
+
from ._common import (
|
|
9
|
+
CommandKind,
|
|
10
|
+
add_leaf,
|
|
11
|
+
read_json_arg,
|
|
12
|
+
read_text_arg,
|
|
13
|
+
require_force,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Name tokens that mark a mutation as irreversible, matched case-insensitively.
|
|
17
|
+
# Substrings cover e.g. sceneDestroy, scenesDestroy, deleteFiles, delete_file,
|
|
18
|
+
# delete_generated, tagsMerge; exact names cover whole-database operations.
|
|
19
|
+
DESTRUCTIVE_SUBSTRINGS = ("destroy", "delete", "merge")
|
|
20
|
+
DESTRUCTIVE_NAMES = frozenset(
|
|
21
|
+
name.lower()
|
|
22
|
+
for name in (
|
|
23
|
+
"metadataClean",
|
|
24
|
+
"migrate",
|
|
25
|
+
"migrateHashNaming",
|
|
26
|
+
"migrateSceneScreenshots",
|
|
27
|
+
"migrateBlobs",
|
|
28
|
+
"anonymiseDatabase",
|
|
29
|
+
"optimiseDatabase",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Yielded by `_tokens` in place of a name when a string literal is unterminated.
|
|
34
|
+
_UNTERMINATED = object()
|
|
35
|
+
|
|
36
|
+
# Brace tokens yielded by `_tokens`; name tokens never equal these.
|
|
37
|
+
_PUNCTUATORS = frozenset({"{", "}"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_graphql(args: argparse.Namespace) -> object:
|
|
41
|
+
query = read_text_arg(args.query)
|
|
42
|
+
variables = read_json_arg(args.variables) if args.variables else None
|
|
43
|
+
if not _is_mutation(query):
|
|
44
|
+
return core.call(query, variables)
|
|
45
|
+
destructive = _destructive_tokens(query)
|
|
46
|
+
if not args.apply:
|
|
47
|
+
return {
|
|
48
|
+
"applied": False,
|
|
49
|
+
"operation": "graphql",
|
|
50
|
+
"input": {"query": query, "variables": variables},
|
|
51
|
+
"destructive": bool(destructive),
|
|
52
|
+
}
|
|
53
|
+
require_force(args, destructive)
|
|
54
|
+
return core.call(query, variables)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_mutation(query: str) -> bool:
|
|
58
|
+
"""Detect a top-level ``mutation`` operation, conservatively.
|
|
59
|
+
|
|
60
|
+
Only the first token of a top-level definition is its keyword, so
|
|
61
|
+
``mutation`` as an alias, field, or operation/fragment name does not
|
|
62
|
+
count. Unterminated strings and unbalanced braces count as mutations.
|
|
63
|
+
"""
|
|
64
|
+
depth = 0
|
|
65
|
+
expect_definition = True
|
|
66
|
+
for token in _tokens(query):
|
|
67
|
+
if token is _UNTERMINATED:
|
|
68
|
+
return True
|
|
69
|
+
if token == "{":
|
|
70
|
+
# At depth 0 with no keyword yet, this is a shorthand query.
|
|
71
|
+
expect_definition = False
|
|
72
|
+
depth += 1
|
|
73
|
+
elif token == "}":
|
|
74
|
+
depth -= 1
|
|
75
|
+
if depth < 0:
|
|
76
|
+
return True
|
|
77
|
+
expect_definition = depth == 0
|
|
78
|
+
elif depth == 0 and expect_definition:
|
|
79
|
+
if token == "mutation":
|
|
80
|
+
return True
|
|
81
|
+
expect_definition = False
|
|
82
|
+
return depth != 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_destructive(query: str) -> bool:
|
|
86
|
+
"""Conservatively detect irreversible operation names outside literals."""
|
|
87
|
+
return bool(_destructive_tokens(query))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _destructive_tokens(query: str) -> list[str]:
|
|
91
|
+
"""Return the distinct destructive names in `query`, in document order."""
|
|
92
|
+
matched: list[str] = []
|
|
93
|
+
for token in _tokens(query):
|
|
94
|
+
if token in _PUNCTUATORS:
|
|
95
|
+
continue
|
|
96
|
+
if token is _UNTERMINATED:
|
|
97
|
+
name = "<unterminated string>"
|
|
98
|
+
else:
|
|
99
|
+
lowered = token.lower()
|
|
100
|
+
if lowered not in DESTRUCTIVE_NAMES and not any(
|
|
101
|
+
part in lowered for part in DESTRUCTIVE_SUBSTRINGS
|
|
102
|
+
):
|
|
103
|
+
continue
|
|
104
|
+
name = token
|
|
105
|
+
if name not in matched:
|
|
106
|
+
matched.append(name)
|
|
107
|
+
return matched
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tokens(query: str):
|
|
111
|
+
"""Yield GraphQL name and brace tokens, skipping comments and strings.
|
|
112
|
+
|
|
113
|
+
Yields `_UNTERMINATED` and stops if a string literal never closes.
|
|
114
|
+
"""
|
|
115
|
+
index = 0
|
|
116
|
+
length = len(query)
|
|
117
|
+
|
|
118
|
+
while index < length:
|
|
119
|
+
character = query[index]
|
|
120
|
+
|
|
121
|
+
if character == "#":
|
|
122
|
+
index += 1
|
|
123
|
+
while index < length and query[index] not in "\r\n":
|
|
124
|
+
index += 1
|
|
125
|
+
if index < length and query[index] == "\r":
|
|
126
|
+
index += 1
|
|
127
|
+
if index < length and query[index] == "\n":
|
|
128
|
+
index += 1
|
|
129
|
+
elif index < length:
|
|
130
|
+
index += 1
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
if character == '"':
|
|
134
|
+
if query.startswith('"""', index):
|
|
135
|
+
index += 3
|
|
136
|
+
while index < length:
|
|
137
|
+
if query.startswith('"""', index):
|
|
138
|
+
index += 3
|
|
139
|
+
break
|
|
140
|
+
if query.startswith('\\"""', index):
|
|
141
|
+
index += 4
|
|
142
|
+
continue
|
|
143
|
+
index += 1
|
|
144
|
+
else:
|
|
145
|
+
yield _UNTERMINATED
|
|
146
|
+
return
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
index += 1
|
|
150
|
+
while index < length:
|
|
151
|
+
character = query[index]
|
|
152
|
+
if character == "\\":
|
|
153
|
+
index += 2
|
|
154
|
+
continue
|
|
155
|
+
if character == '"':
|
|
156
|
+
index += 1
|
|
157
|
+
break
|
|
158
|
+
if character in "\r\n":
|
|
159
|
+
yield _UNTERMINATED
|
|
160
|
+
return
|
|
161
|
+
index += 1
|
|
162
|
+
else:
|
|
163
|
+
yield _UNTERMINATED
|
|
164
|
+
return
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if character.isascii() and (character.isalpha() or character == "_"):
|
|
168
|
+
token_start = index
|
|
169
|
+
index += 1
|
|
170
|
+
while (
|
|
171
|
+
index < length
|
|
172
|
+
and query[index].isascii()
|
|
173
|
+
and (query[index].isalnum() or query[index] == "_")
|
|
174
|
+
):
|
|
175
|
+
index += 1
|
|
176
|
+
yield query[token_start:index]
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if character in _PUNCTUATORS:
|
|
180
|
+
yield character
|
|
181
|
+
|
|
182
|
+
index += 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
186
|
+
parser = add_leaf(
|
|
187
|
+
subparsers,
|
|
188
|
+
"graphql",
|
|
189
|
+
kind=CommandKind.DESTRUCTIVE,
|
|
190
|
+
func=cmd_graphql,
|
|
191
|
+
help="Send a raw GraphQL document to Stash (escape hatch).",
|
|
192
|
+
examples=(
|
|
193
|
+
"stashapp-cli graphql --query 'query { findTags { tags { id } } }'",
|
|
194
|
+
'stashapp-cli graphql --query @query.graphql --variables \'{"name": "hd"}\'',
|
|
195
|
+
"stashapp-cli graphql --query @mutation.graphql --apply",
|
|
196
|
+
"stashapp-cli graphql --query @destroy.graphql --apply --force",
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--query", required=True, help="GraphQL document as text, @file, or @-."
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--variables",
|
|
204
|
+
default=None,
|
|
205
|
+
help="Variables as JSON, @file, or @- (default: none).",
|
|
206
|
+
)
|
|
207
|
+
parser.epilog = f"{parser.epilog}" + (
|
|
208
|
+
"\n\nQueries are sent as-is. Mutations are dry runs unless --apply is "
|
|
209
|
+
"given.\nDestructive mutations (names containing destroy/delete/merge, "
|
|
210
|
+
"metadataClean,\nmigrate, migrateBlobs, ... database maintenance) also "
|
|
211
|
+
"need --force; without\nit they exit 2 with code destructive_mutation."
|
|
212
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""group commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import queries
|
|
8
|
+
from ._common import (
|
|
9
|
+
TAG_IDS,
|
|
10
|
+
URLS,
|
|
11
|
+
CommandKind,
|
|
12
|
+
FindSpec,
|
|
13
|
+
add_find_options,
|
|
14
|
+
add_get_command,
|
|
15
|
+
add_leaf,
|
|
16
|
+
add_list_options,
|
|
17
|
+
add_update_target,
|
|
18
|
+
bulk_update_input,
|
|
19
|
+
find_filter,
|
|
20
|
+
mutation_result,
|
|
21
|
+
read_json_arg,
|
|
22
|
+
require_list_target,
|
|
23
|
+
resolve_update_input,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
FIND = FindSpec("groups", "name", match_aliases=False, sort="name")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_group_find(args: argparse.Namespace) -> object:
|
|
30
|
+
extra = None
|
|
31
|
+
if args.studio_id is not None:
|
|
32
|
+
extra = {"studios": {"value": [args.studio_id], "modifier": "INCLUDES"}}
|
|
33
|
+
return queries.group.find_groups(
|
|
34
|
+
find_filter(args, FIND, extra), args.page, args.per_page
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_group_create(args: argparse.Namespace) -> object:
|
|
39
|
+
input_data = read_json_arg(args.input)
|
|
40
|
+
return mutation_result(
|
|
41
|
+
"groupCreate",
|
|
42
|
+
args.apply,
|
|
43
|
+
input_data,
|
|
44
|
+
lambda: queries.group.create_group(input_data),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
LIST_FIELDS = (TAG_IDS, URLS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_group_update(args: argparse.Namespace) -> object:
|
|
52
|
+
bulk_input = bulk_update_input(args, LIST_FIELDS)
|
|
53
|
+
if bulk_input is not None:
|
|
54
|
+
require_list_target(args)
|
|
55
|
+
return mutation_result(
|
|
56
|
+
"bulkGroupUpdate",
|
|
57
|
+
args.apply,
|
|
58
|
+
bulk_input,
|
|
59
|
+
lambda: queries.group.bulk_update_group(bulk_input),
|
|
60
|
+
)
|
|
61
|
+
input_data = resolve_update_input(args)
|
|
62
|
+
return mutation_result(
|
|
63
|
+
"groupUpdate",
|
|
64
|
+
args.apply,
|
|
65
|
+
input_data,
|
|
66
|
+
lambda: queries.group.update_group(input_data),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
71
|
+
group = subparsers.add_parser("group", help="Group lookups and writes.")
|
|
72
|
+
group_sub = group.add_subparsers(dest="group_command", required=True)
|
|
73
|
+
|
|
74
|
+
add_get_command(group_sub, "group", queries.group.get_group, example_id=5)
|
|
75
|
+
|
|
76
|
+
find = add_leaf(
|
|
77
|
+
group_sub,
|
|
78
|
+
"find",
|
|
79
|
+
kind=CommandKind.READ,
|
|
80
|
+
func=cmd_group_find,
|
|
81
|
+
help=(
|
|
82
|
+
"Find groups by exact name (not alias), studio, or a GroupFilterType. "
|
|
83
|
+
"Several matches are not an error; check count."
|
|
84
|
+
),
|
|
85
|
+
examples=(
|
|
86
|
+
"stashapp-cli group find --name 'Film' --studio-id 1",
|
|
87
|
+
"stashapp-cli group find --name 'Film' | jq 'select(.count == 1) | .groups[0]'",
|
|
88
|
+
'stashapp-cli group find --filter-json \'{"is_missing": "studio"}\'',
|
|
89
|
+
"stashapp-cli group find --studio-id 1 --filter-json @group-filter.json",
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
add_find_options(find, FIND)
|
|
93
|
+
find.add_argument(
|
|
94
|
+
"--studio-id",
|
|
95
|
+
default=None,
|
|
96
|
+
metavar="ID",
|
|
97
|
+
help="Only match groups in this studio (the studios criterion).",
|
|
98
|
+
)
|
|
99
|
+
find.epilog = f"{find.epilog}" + (
|
|
100
|
+
"\n\nStash cannot filter groups by alias, so --name matches the name only.\n"
|
|
101
|
+
"--studio-id follows the same rules as --name: it is added to the filter\n"
|
|
102
|
+
"and conflicts with a studios criterion or a top-level AND, OR, or NOT.\n"
|
|
103
|
+
"More than one group can match; check that count is 1 before using a\n"
|
|
104
|
+
"match as one group."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
create = add_leaf(
|
|
108
|
+
group_sub,
|
|
109
|
+
"create",
|
|
110
|
+
kind=CommandKind.WRITE,
|
|
111
|
+
func=cmd_group_create,
|
|
112
|
+
help="Create a group (dry-run by default; see global --apply).",
|
|
113
|
+
examples=(
|
|
114
|
+
'stashapp-cli group create --input \'{"name": "Film"}\'',
|
|
115
|
+
"stashapp-cli group create --input @group.json --apply",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
create.add_argument(
|
|
119
|
+
"--input", required=True, help="GroupCreateInput as JSON, @file, or @-."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
update = add_leaf(
|
|
123
|
+
group_sub,
|
|
124
|
+
"update",
|
|
125
|
+
kind=CommandKind.WRITE,
|
|
126
|
+
func=cmd_group_update,
|
|
127
|
+
help="Update a group (dry-run by default; see global --apply).",
|
|
128
|
+
examples=(
|
|
129
|
+
'stashapp-cli group update 1 --input \'{"name": "Film"}\'',
|
|
130
|
+
'stashapp-cli group update --input \'{"id": "1", "name": "Film"}\'',
|
|
131
|
+
"stashapp-cli --apply group update --input @group.json",
|
|
132
|
+
"stashapp-cli group update 5 --add-tag-id 7 --remove-url https://old.example/film",
|
|
133
|
+
"stashapp-cli group update 5 --add-url https://example.com/film --apply",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
add_update_target(update, "GroupUpdateInput")
|
|
137
|
+
add_list_options(update, LIST_FIELDS)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""introspect command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import core, introspect
|
|
8
|
+
from ._common import CommandKind, add_leaf
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_introspect(_args: argparse.Namespace) -> object:
|
|
12
|
+
missing = introspect.missing_schema_names()
|
|
13
|
+
if missing:
|
|
14
|
+
raise core.CliError(
|
|
15
|
+
"Schema drift detected, missing: " + ", ".join(missing),
|
|
16
|
+
1,
|
|
17
|
+
"schema_drift",
|
|
18
|
+
details={"missing": missing},
|
|
19
|
+
)
|
|
20
|
+
return {"drift": False, "missing": []}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
24
|
+
add_leaf(
|
|
25
|
+
subparsers,
|
|
26
|
+
"introspect",
|
|
27
|
+
kind=CommandKind.READ,
|
|
28
|
+
func=cmd_introspect,
|
|
29
|
+
help=(
|
|
30
|
+
"Check the live Stash schema against the names this CLI depends "
|
|
31
|
+
"on. Drift exits 1 with code schema_drift; --json-errors adds "
|
|
32
|
+
"error.missing."
|
|
33
|
+
),
|
|
34
|
+
examples=("stashapp-cli introspect",),
|
|
35
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""job commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .. import core, queries
|
|
9
|
+
from ._common import CommandKind, add_leaf, object_id, positive_seconds
|
|
10
|
+
|
|
11
|
+
_RECENT_JOBS_NOTE = "Stash keeps only its most recent finished jobs."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_job_get(args: argparse.Namespace) -> object:
|
|
15
|
+
return queries.job.find_job(object_id(args.id, "Job"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_job_wait(args: argparse.Namespace) -> object:
|
|
19
|
+
job_id = object_id(args.id, "Job")
|
|
20
|
+
deadline = time.monotonic() + args.max_wait
|
|
21
|
+
while True:
|
|
22
|
+
job = queries.job.find_job(job_id)
|
|
23
|
+
if job is None:
|
|
24
|
+
raise core.CliError(
|
|
25
|
+
f"Stash has no job {job_id}. {_RECENT_JOBS_NOTE} Check the "
|
|
26
|
+
"result with a read command, for example `scene find`.",
|
|
27
|
+
1,
|
|
28
|
+
"job_not_found",
|
|
29
|
+
)
|
|
30
|
+
status = job.get("status")
|
|
31
|
+
if status == "FINISHED":
|
|
32
|
+
return job
|
|
33
|
+
if status == "FAILED":
|
|
34
|
+
raise core.CliError(
|
|
35
|
+
f"Job {job_id} failed: {job.get('error') or '(no error message)'}",
|
|
36
|
+
1,
|
|
37
|
+
"job_failed",
|
|
38
|
+
details={"job": job},
|
|
39
|
+
)
|
|
40
|
+
if status == "CANCELLED":
|
|
41
|
+
raise core.CliError(
|
|
42
|
+
f"Job {job_id} was cancelled.",
|
|
43
|
+
1,
|
|
44
|
+
"job_cancelled",
|
|
45
|
+
details={"job": job},
|
|
46
|
+
)
|
|
47
|
+
remaining = deadline - time.monotonic()
|
|
48
|
+
if remaining <= 0:
|
|
49
|
+
raise core.CliError(
|
|
50
|
+
f"Job {job_id} is still running in Stash (status {status}) after "
|
|
51
|
+
f"{args.max_wait:g} seconds. Run `stashapp-cli job wait {job_id}` "
|
|
52
|
+
"again to keep waiting.",
|
|
53
|
+
1,
|
|
54
|
+
"job_wait_timeout",
|
|
55
|
+
details={"job": job},
|
|
56
|
+
)
|
|
57
|
+
time.sleep(min(args.interval, remaining))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _add_job_id(parser: argparse.ArgumentParser) -> None:
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"id",
|
|
63
|
+
metavar="ID",
|
|
64
|
+
help="Job id, as printed in job_id by scan run, generate run, or "
|
|
65
|
+
"scene identify.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
70
|
+
job = subparsers.add_parser("job", help="Read and wait for Stash background jobs.")
|
|
71
|
+
job_sub = job.add_subparsers(dest="job_command", required=True)
|
|
72
|
+
|
|
73
|
+
get = add_leaf(
|
|
74
|
+
job_sub,
|
|
75
|
+
"get",
|
|
76
|
+
kind=CommandKind.READ,
|
|
77
|
+
func=cmd_job_get,
|
|
78
|
+
help="Fetch one job's state, or null if Stash has no such job.",
|
|
79
|
+
examples=("stashapp-cli job get 17",),
|
|
80
|
+
)
|
|
81
|
+
get.description = (
|
|
82
|
+
"Print a job's id, status, progress, description, error, addTime,\n"
|
|
83
|
+
"startTime, and endTime. Print null when Stash has no job with that id.\n"
|
|
84
|
+
f"{_RECENT_JOBS_NOTE}"
|
|
85
|
+
)
|
|
86
|
+
_add_job_id(get)
|
|
87
|
+
|
|
88
|
+
wait = add_leaf(
|
|
89
|
+
job_sub,
|
|
90
|
+
"wait",
|
|
91
|
+
kind=CommandKind.READ,
|
|
92
|
+
func=cmd_job_wait,
|
|
93
|
+
help="Wait until a job is FINISHED, CANCELLED, or FAILED.",
|
|
94
|
+
examples=(
|
|
95
|
+
"stashapp-cli job wait 17",
|
|
96
|
+
"stashapp-cli --json-errors job wait 17 --max-wait 600 --interval 5",
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
wait.description = (
|
|
100
|
+
"Read the job at once, then every --interval seconds, until it ends.\n"
|
|
101
|
+
"Print the job when it is FINISHED. Fail with job_failed, job_cancelled,\n"
|
|
102
|
+
"or job_wait_timeout (exit 1; --json-errors adds `job`, the last state\n"
|
|
103
|
+
"read), or job_not_found (exit 1) when Stash has no such job.\n"
|
|
104
|
+
f"{_RECENT_JOBS_NOTE}\n"
|
|
105
|
+
"Each read uses the global --timeout, so a wait can run past --max-wait\n"
|
|
106
|
+
"by up to one --timeout."
|
|
107
|
+
)
|
|
108
|
+
_add_job_id(wait)
|
|
109
|
+
wait.add_argument(
|
|
110
|
+
"--max-wait",
|
|
111
|
+
type=positive_seconds,
|
|
112
|
+
default=3600.0,
|
|
113
|
+
metavar="SECONDS",
|
|
114
|
+
help="Stop waiting after this many seconds (default: 3600).",
|
|
115
|
+
)
|
|
116
|
+
wait.add_argument(
|
|
117
|
+
"--interval",
|
|
118
|
+
type=positive_seconds,
|
|
119
|
+
default=2.0,
|
|
120
|
+
metavar="SECONDS",
|
|
121
|
+
help="Seconds between reads (default: 2).",
|
|
122
|
+
)
|