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,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
+ )