stashapp-client 0.1.3__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_client/__init__.py +74 -0
- stashapp_client/cli.py +93 -0
- stashapp_client/client.py +126 -0
- stashapp_client/codegen.py +176 -0
- stashapp_client/criteria.py +73 -0
- stashapp_client/errors.py +29 -0
- stashapp_client/filters.py +64 -0
- stashapp_client/fragments.py +56 -0
- stashapp_client/generated/__init__.py +1 -0
- stashapp_client/generated/fragments.py +111 -0
- stashapp_client/generated/inputs.py +13 -0
- stashapp_client/generated/operations_registry.json +10012 -0
- stashapp_client/generated/schema.json +43838 -0
- stashapp_client/input_validation.py +83 -0
- stashapp_client/introspection.py +104 -0
- stashapp_client/mutation.py +129 -0
- stashapp_client/pagination.py +57 -0
- stashapp_client/py.typed +0 -0
- stashapp_client/registry.py +125 -0
- stashapp_client/response.py +64 -0
- stashapp_client/runtime_bind.py +201 -0
- stashapp_client/schema_diff.py +134 -0
- stashapp_client-0.1.3.dist-info/METADATA +205 -0
- stashapp_client-0.1.3.dist-info/RECORD +28 -0
- stashapp_client-0.1.3.dist-info/WHEEL +5 -0
- stashapp_client-0.1.3.dist-info/entry_points.txt +2 -0
- stashapp_client-0.1.3.dist-info/licenses/LICENSE +21 -0
- stashapp_client-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Python client for the Stash GraphQL API."""
|
|
2
|
+
|
|
3
|
+
from .client import StashClient
|
|
4
|
+
from .criteria import (
|
|
5
|
+
between,
|
|
6
|
+
equals,
|
|
7
|
+
excludes,
|
|
8
|
+
gql_criterion,
|
|
9
|
+
greater_than,
|
|
10
|
+
includes,
|
|
11
|
+
includes_all,
|
|
12
|
+
is_null,
|
|
13
|
+
less_than,
|
|
14
|
+
matches_regex,
|
|
15
|
+
not_equals,
|
|
16
|
+
not_matches_regex,
|
|
17
|
+
not_null,
|
|
18
|
+
stash_id,
|
|
19
|
+
stash_ids,
|
|
20
|
+
)
|
|
21
|
+
from .errors import (
|
|
22
|
+
GraphQLError,
|
|
23
|
+
StashConnectionError,
|
|
24
|
+
StashError,
|
|
25
|
+
StashResponseError,
|
|
26
|
+
TransportError,
|
|
27
|
+
)
|
|
28
|
+
from .filters import (
|
|
29
|
+
find_filter,
|
|
30
|
+
gallery_filter,
|
|
31
|
+
group_filter,
|
|
32
|
+
image_filter,
|
|
33
|
+
marker_filter,
|
|
34
|
+
performer_filter,
|
|
35
|
+
scene_filter,
|
|
36
|
+
studio_filter,
|
|
37
|
+
tag_filter,
|
|
38
|
+
)
|
|
39
|
+
from .mutation import MutationPlan, prepare_mutations
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"GraphQLError",
|
|
43
|
+
"MutationPlan",
|
|
44
|
+
"StashClient",
|
|
45
|
+
"StashConnectionError",
|
|
46
|
+
"StashError",
|
|
47
|
+
"StashResponseError",
|
|
48
|
+
"TransportError",
|
|
49
|
+
"between",
|
|
50
|
+
"equals",
|
|
51
|
+
"excludes",
|
|
52
|
+
"find_filter",
|
|
53
|
+
"gallery_filter",
|
|
54
|
+
"gql_criterion",
|
|
55
|
+
"greater_than",
|
|
56
|
+
"group_filter",
|
|
57
|
+
"image_filter",
|
|
58
|
+
"includes",
|
|
59
|
+
"includes_all",
|
|
60
|
+
"is_null",
|
|
61
|
+
"less_than",
|
|
62
|
+
"marker_filter",
|
|
63
|
+
"matches_regex",
|
|
64
|
+
"not_equals",
|
|
65
|
+
"not_matches_regex",
|
|
66
|
+
"not_null",
|
|
67
|
+
"performer_filter",
|
|
68
|
+
"prepare_mutations",
|
|
69
|
+
"scene_filter",
|
|
70
|
+
"stash_id",
|
|
71
|
+
"stash_ids",
|
|
72
|
+
"studio_filter",
|
|
73
|
+
"tag_filter",
|
|
74
|
+
]
|
stashapp_client/cli.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Command-line entry point for schema and operation generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import StashClient
|
|
10
|
+
from .codegen import attach_documents, render_fragments, render_inputs
|
|
11
|
+
from .introspection import introspect_schema, load_schema, save_schema
|
|
12
|
+
from .registry import build_registry, save_registry
|
|
13
|
+
from .schema_diff import compare_schemas, format_report, has_changes, report_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _path_options(parser: argparse.ArgumentParser) -> None:
|
|
17
|
+
parser.add_argument("--out-schema", type=Path, default=Path("schema.json"))
|
|
18
|
+
parser.add_argument("--out-registry", type=Path, default=Path("operations_registry.json"))
|
|
19
|
+
parser.add_argument("--out-fragments", type=Path, default=Path("fragments.py"))
|
|
20
|
+
parser.add_argument("--out-inputs", type=Path, default=Path("inputs.py"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write_artifacts(schema: dict[str, Any], provenance: dict[str, Any], args: Any) -> None:
|
|
24
|
+
save_schema({"data": {"__schema": schema}}, args.out_schema)
|
|
25
|
+
registry = attach_documents(build_registry(schema), schema)
|
|
26
|
+
registry["provenance"] = provenance
|
|
27
|
+
save_registry(registry, args.out_registry)
|
|
28
|
+
args.out_fragments.write_text(render_fragments(schema), encoding="utf-8")
|
|
29
|
+
args.out_inputs.write_text(render_inputs(schema), encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _verify_value(value: str) -> bool | str:
|
|
33
|
+
lowered = value.lower()
|
|
34
|
+
if lowered == "true":
|
|
35
|
+
return True
|
|
36
|
+
if lowered == "false":
|
|
37
|
+
return False
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="stashapp-client")
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
build = commands.add_parser("build-ops", help="build artifacts from a schema snapshot")
|
|
46
|
+
build.add_argument("--schema", type=Path, required=True)
|
|
47
|
+
_path_options(build)
|
|
48
|
+
|
|
49
|
+
sync = commands.add_parser("sync-schema", help="introspect a live server and build artifacts")
|
|
50
|
+
sync.add_argument("--credentials-file", type=Path, required=True)
|
|
51
|
+
sync.add_argument("--verify", type=_verify_value, default=True)
|
|
52
|
+
sync.add_argument("--timeout", type=float, default=30)
|
|
53
|
+
_path_options(sync)
|
|
54
|
+
|
|
55
|
+
diff = commands.add_parser("diff-schema", help="compare two schema snapshots")
|
|
56
|
+
diff.add_argument("--base", type=Path, required=True)
|
|
57
|
+
diff.add_argument("--current", type=Path, required=True)
|
|
58
|
+
diff.add_argument("--json", action="store_true", dest="as_json")
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> None:
|
|
63
|
+
args = _parser().parse_args(argv)
|
|
64
|
+
if args.command == "build-ops":
|
|
65
|
+
_write_artifacts(
|
|
66
|
+
load_schema(args.schema),
|
|
67
|
+
{"source": "introspection-snapshot", "path": str(args.schema)},
|
|
68
|
+
args,
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
if args.command == "diff-schema":
|
|
72
|
+
report = compare_schemas(load_schema(args.base), load_schema(args.current))
|
|
73
|
+
print(report_json(report) if args.as_json else format_report(report))
|
|
74
|
+
if has_changes(report):
|
|
75
|
+
raise SystemExit(1)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
client = StashClient.from_credentials_file(
|
|
79
|
+
args.credentials_file, verify=args.verify, timeout=args.timeout
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
schema = introspect_schema(client)
|
|
83
|
+
finally:
|
|
84
|
+
client.close()
|
|
85
|
+
_write_artifacts(
|
|
86
|
+
schema,
|
|
87
|
+
{"source": "live-introspection", "path": str(args.credentials_file)},
|
|
88
|
+
args,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Stash client configuration and low-level GraphQL execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests import Session
|
|
11
|
+
|
|
12
|
+
from .errors import StashConnectionError, StashResponseError, TransportError
|
|
13
|
+
from .response import extract_response
|
|
14
|
+
from .runtime_bind import load_and_bind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StashClient:
|
|
18
|
+
"""A configured client for one Stash GraphQL endpoint."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
url: str,
|
|
23
|
+
api_key: str,
|
|
24
|
+
*,
|
|
25
|
+
verify: bool | str = True,
|
|
26
|
+
timeout: float = 30,
|
|
27
|
+
session: Session | None = None,
|
|
28
|
+
registry_path: str | os.PathLike[str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not url:
|
|
31
|
+
raise ValueError("url is required")
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ValueError("api_key is required")
|
|
34
|
+
self.url = url
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.verify = verify
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.session = session or requests.Session()
|
|
39
|
+
selected_registry = registry_path or Path(__file__).parent / "generated" / "operations_registry.json"
|
|
40
|
+
if selected_registry.exists():
|
|
41
|
+
load_and_bind(self, str(selected_registry))
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_credentials_file(
|
|
45
|
+
cls,
|
|
46
|
+
path: str | os.PathLike[str],
|
|
47
|
+
*,
|
|
48
|
+
verify: bool | str = True,
|
|
49
|
+
timeout: float = 30,
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> "StashClient": # noqa: UP037
|
|
52
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
53
|
+
values = [line.strip() for line in lines if line.strip()]
|
|
54
|
+
if len(values) < 2:
|
|
55
|
+
raise ValueError("credentials file must contain URL and API key")
|
|
56
|
+
if isinstance(verify, str) and not os.path.isabs(verify):
|
|
57
|
+
verify = str(Path(path).parent / verify)
|
|
58
|
+
return cls(values[0], values[1], verify=verify, timeout=timeout, **kwargs)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_env(
|
|
62
|
+
cls, *, verify: bool | str = True, timeout: float = 30, **kwargs: Any
|
|
63
|
+
) -> "StashClient": # noqa: UP037
|
|
64
|
+
configured_verify = os.environ.get("STASHAPI_TLS_VERIFY")
|
|
65
|
+
if configured_verify is not None:
|
|
66
|
+
verify = _parse_verify(configured_verify)
|
|
67
|
+
return cls(
|
|
68
|
+
os.environ.get("STASH_URL", ""),
|
|
69
|
+
os.environ.get("STASH_API_KEY", ""),
|
|
70
|
+
verify=verify,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
**kwargs,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def execute(
|
|
76
|
+
self,
|
|
77
|
+
query: str,
|
|
78
|
+
variables: dict[str, Any] | None = None,
|
|
79
|
+
*,
|
|
80
|
+
response: str = "data",
|
|
81
|
+
field: str | list[str] | None = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Execute a GraphQL document and extract its result."""
|
|
84
|
+
try:
|
|
85
|
+
result = self.session.post(
|
|
86
|
+
self.url,
|
|
87
|
+
json={"query": query, "variables": variables or {}},
|
|
88
|
+
headers={"ApiKey": self.api_key, "Content-Type": "application/json"},
|
|
89
|
+
verify=self.verify,
|
|
90
|
+
timeout=self.timeout,
|
|
91
|
+
)
|
|
92
|
+
result.raise_for_status()
|
|
93
|
+
envelope = result.json()
|
|
94
|
+
except requests.RequestException as exc:
|
|
95
|
+
raise TransportError(str(exc)) from exc
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise StashResponseError("server returned invalid JSON") from exc
|
|
98
|
+
if not isinstance(envelope, dict):
|
|
99
|
+
raise StashResponseError("GraphQL response must be a JSON object")
|
|
100
|
+
return extract_response(envelope, response=response, field=field)
|
|
101
|
+
|
|
102
|
+
def has_connection(self) -> bool:
|
|
103
|
+
"""Return whether the endpoint accepts a minimal GraphQL request."""
|
|
104
|
+
try:
|
|
105
|
+
self.execute("query ConnectionCheck { __typename }")
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise StashConnectionError(str(exc)) from exc
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self.session.close()
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "StashClient": # noqa: PYI034, UP037
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *args: object) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_verify(value: str) -> bool | str:
|
|
121
|
+
lowered = value.strip().lower()
|
|
122
|
+
if lowered == "true":
|
|
123
|
+
return True
|
|
124
|
+
if lowered == "false":
|
|
125
|
+
return False
|
|
126
|
+
return value
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Render deterministic Python artifacts from schema-derived metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .fragments import combine_fragments, get_dependent_fragments
|
|
9
|
+
from .registry import _compact_selection, _has_required_arguments, _named_type
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_inputs(schema: dict[str, Any]) -> str:
|
|
13
|
+
"""Render schema-derived input metadata and validation helpers."""
|
|
14
|
+
specs = build_input_specs(schema)
|
|
15
|
+
lines = [
|
|
16
|
+
'"""Generated GraphQL input metadata and validation helpers."""',
|
|
17
|
+
"",
|
|
18
|
+
"from typing import Any",
|
|
19
|
+
"",
|
|
20
|
+
"from stashapp_client.input_validation import validate_input_value",
|
|
21
|
+
"",
|
|
22
|
+
f"INPUT_FIELDS = {specs!r}",
|
|
23
|
+
"",
|
|
24
|
+
"def validate_input(name: str, value: Any) -> Any:",
|
|
25
|
+
" \"\"\"Validate one generated GraphQL input object and return it.\"\"\"",
|
|
26
|
+
" return validate_input_value(name, value, INPUT_FIELDS)",
|
|
27
|
+
"",
|
|
28
|
+
"__all__ = [\"INPUT_FIELDS\", \"validate_input\"]",
|
|
29
|
+
"",
|
|
30
|
+
]
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_input_specs(schema: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]:
|
|
35
|
+
"""Return input object fields with GraphQL type and required metadata."""
|
|
36
|
+
specs: dict[str, dict[str, dict[str, Any]]] = {}
|
|
37
|
+
for definition in schema.get("types", []):
|
|
38
|
+
name = definition.get("name")
|
|
39
|
+
if definition.get("kind") != "INPUT_OBJECT" or not name:
|
|
40
|
+
continue
|
|
41
|
+
specs[name] = {
|
|
42
|
+
field["name"]: {
|
|
43
|
+
"type": _type_string(field.get("type", {})),
|
|
44
|
+
"required": field.get("type", {}).get("kind") == "NON_NULL",
|
|
45
|
+
}
|
|
46
|
+
for field in definition.get("inputFields", []) or []
|
|
47
|
+
if field.get("name")
|
|
48
|
+
}
|
|
49
|
+
return dict(sorted(specs.items()))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _type_string(type_ref: dict[str, Any]) -> str:
|
|
53
|
+
if type_ref.get("kind") == "NON_NULL":
|
|
54
|
+
return f"{_type_string(type_ref.get('ofType', {}))}!"
|
|
55
|
+
if type_ref.get("kind") == "LIST":
|
|
56
|
+
return f"[{_type_string(type_ref.get('ofType', {}))}]"
|
|
57
|
+
return type_ref.get("name", "Unknown")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_fragments(schema: dict[str, Any]) -> str:
|
|
61
|
+
"""Render full root fragments with bounded nested object selections."""
|
|
62
|
+
fragments = build_fragments(schema)
|
|
63
|
+
lines = [
|
|
64
|
+
'"""Generated schema-derived GraphQL fragments. Do not edit by hand."""',
|
|
65
|
+
"",
|
|
66
|
+
f"FRAGMENTS = {json.dumps(fragments, indent=2, sort_keys=True)}",
|
|
67
|
+
"",
|
|
68
|
+
"__all__ = [\"FRAGMENTS\"]",
|
|
69
|
+
"",
|
|
70
|
+
]
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_fragments(schema: dict[str, Any]) -> dict[str, str]:
|
|
75
|
+
"""Build full root fragment documents with bounded nested selections."""
|
|
76
|
+
types = {item["name"]: item for item in schema.get("types", []) if item.get("name")}
|
|
77
|
+
fragments: dict[str, str] = {}
|
|
78
|
+
for name, definition in sorted(types.items()):
|
|
79
|
+
if definition.get("kind") in {"UNION", "INTERFACE"} and not name.startswith("__"):
|
|
80
|
+
possible = definition.get("possibleTypes", []) or []
|
|
81
|
+
spreads = " ".join(
|
|
82
|
+
f"...{item['name']}" for item in possible if item.get("name")
|
|
83
|
+
)
|
|
84
|
+
fragments[name] = f"fragment {name} on {name} {{ {spreads or '__typename'} }}"
|
|
85
|
+
continue
|
|
86
|
+
if definition.get("kind") != "OBJECT" or name.startswith("__"):
|
|
87
|
+
continue
|
|
88
|
+
selection = _root_selection(name, types)
|
|
89
|
+
fragments[name] = f"fragment {name} on {name} {{ {selection} }}"
|
|
90
|
+
|
|
91
|
+
return fragments
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def attach_documents(registry: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
95
|
+
"""Add fragment-backed GraphQL documents to a registry in place."""
|
|
96
|
+
fragments = build_fragments(schema)
|
|
97
|
+
registry["input_fields"] = build_input_specs(schema)
|
|
98
|
+
registry["field_types"] = build_field_specs(schema)
|
|
99
|
+
for operation in registry.get("operations", []):
|
|
100
|
+
result_type = operation.get("result_type")
|
|
101
|
+
if result_type not in fragments:
|
|
102
|
+
continue
|
|
103
|
+
operation["fragment_name"] = result_type
|
|
104
|
+
operation["document"] = _operation_document(operation, fragments)
|
|
105
|
+
return registry
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_field_specs(schema: dict[str, Any]) -> dict[str, dict[str, str | None]]:
|
|
109
|
+
"""Return schema-derived field-to-named-type metadata for path validation."""
|
|
110
|
+
specs: dict[str, dict[str, str | None]] = {}
|
|
111
|
+
for definition in schema.get("types", []):
|
|
112
|
+
name = definition.get("name")
|
|
113
|
+
if not name:
|
|
114
|
+
continue
|
|
115
|
+
specs[name] = {
|
|
116
|
+
field["name"]: _named_type(field.get("type", {}))
|
|
117
|
+
for field in definition.get("fields", []) or []
|
|
118
|
+
if field.get("name")
|
|
119
|
+
}
|
|
120
|
+
return dict(sorted(specs.items()))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _operation_document(operation: dict[str, Any], fragments: dict[str, str]) -> str:
|
|
124
|
+
kind = operation.get("kind", "query")
|
|
125
|
+
name = operation["name"]
|
|
126
|
+
arguments = operation.get("arguments", [])
|
|
127
|
+
definitions = ", ".join(f"${item['name']}: {item['type']}" for item in arguments)
|
|
128
|
+
passed = ", ".join(f"{item['name']}: ${item['name']}" for item in arguments)
|
|
129
|
+
fragment_name = operation["fragment_name"]
|
|
130
|
+
dependencies = get_dependent_fragments(fragment_name, fragments)
|
|
131
|
+
fragment_definitions = combine_fragments(fragment_name, dependencies, fragments)
|
|
132
|
+
variable_part = f"({definitions})" if definitions else ""
|
|
133
|
+
passed_part = f"({passed})" if passed else ""
|
|
134
|
+
document = (
|
|
135
|
+
f"{kind} {name}{variable_part} {{ "
|
|
136
|
+
f"{name}{passed_part} {{ ...{fragment_name} }} }}"
|
|
137
|
+
)
|
|
138
|
+
return f"{document}\n{fragment_definitions}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _root_selection(type_name: str, types: dict[str, dict[str, Any]]) -> str:
|
|
142
|
+
definition = types.get(type_name, {})
|
|
143
|
+
selections: list[str] = []
|
|
144
|
+
for field in definition.get("fields", []) or []:
|
|
145
|
+
if field.get("isDeprecated") or _has_required_arguments(field.get("args", [])):
|
|
146
|
+
continue
|
|
147
|
+
field_type = _named_type(field.get("type", {}))
|
|
148
|
+
field_definition = types.get(field_type or "", {})
|
|
149
|
+
if field_definition.get("kind") in {"OBJECT", "UNION", "INTERFACE"}:
|
|
150
|
+
if _reenters(field_type or "", type_name, types):
|
|
151
|
+
nested = _compact_selection(field_type, types)
|
|
152
|
+
else:
|
|
153
|
+
nested = f"...{field_type}"
|
|
154
|
+
selections.append(f"{field['name']} {{ {nested} }}")
|
|
155
|
+
elif field_type:
|
|
156
|
+
selections.append(field["name"])
|
|
157
|
+
return " ".join(selections) or "__typename"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _reenters(type_name: str, target: str, types: dict[str, dict[str, Any]]) -> bool:
|
|
161
|
+
"""Return whether following object fields from a type can reach the target."""
|
|
162
|
+
pending = [type_name]
|
|
163
|
+
visited: set[str] = set()
|
|
164
|
+
while pending:
|
|
165
|
+
current = pending.pop()
|
|
166
|
+
if current == target:
|
|
167
|
+
return True
|
|
168
|
+
if current in visited:
|
|
169
|
+
continue
|
|
170
|
+
visited.add(current)
|
|
171
|
+
definition = types.get(current, {})
|
|
172
|
+
for field in definition.get("fields", []) or []:
|
|
173
|
+
nested = _named_type(field.get("type", {}))
|
|
174
|
+
if types.get(nested or "", {}).get("kind") == "OBJECT":
|
|
175
|
+
pending.append(nested)
|
|
176
|
+
return False
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Small builders for Stash GraphQL filter criteria."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gql_criterion(modifier: str, value: Any = None, **extra: Any) -> dict[str, Any]:
|
|
7
|
+
criterion: dict[str, Any] = {"modifier": modifier}
|
|
8
|
+
if value is not None:
|
|
9
|
+
criterion["value"] = value
|
|
10
|
+
criterion.update(extra)
|
|
11
|
+
return criterion
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def equals(value: Any) -> dict[str, Any]:
|
|
15
|
+
return gql_criterion("EQUALS", value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def not_equals(value: Any) -> dict[str, Any]:
|
|
19
|
+
return gql_criterion("NOT_EQUALS", value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def includes(value: Any, *, depth: int | None = None, excludes: Any = None) -> dict[str, Any]:
|
|
23
|
+
return _hierarchy("INCLUDES", value, depth=depth, excludes=excludes)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def includes_all(value: Any, *, depth: int | None = None, excludes: Any = None) -> dict[str, Any]:
|
|
27
|
+
return _hierarchy("INCLUDES_ALL", value, depth=depth, excludes=excludes)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def excludes(value: Any, *, depth: int | None = None) -> dict[str, Any]:
|
|
31
|
+
return _hierarchy("EXCLUDES", value, depth=depth)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def between(start: Any, end: Any) -> dict[str, Any]:
|
|
35
|
+
return {"value": start, "value2": end, "modifier": "BETWEEN"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def greater_than(value: Any) -> dict[str, Any]:
|
|
39
|
+
return gql_criterion("GREATER_THAN", value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def less_than(value: Any) -> dict[str, Any]:
|
|
43
|
+
return gql_criterion("LESS_THAN", value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def matches_regex(value: str) -> dict[str, Any]:
|
|
47
|
+
return gql_criterion("MATCHES_REGEX", value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def not_matches_regex(value: str) -> dict[str, Any]:
|
|
51
|
+
return gql_criterion("NOT_MATCHES_REGEX", value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_null(value: bool = True) -> dict[str, Any]:
|
|
55
|
+
return gql_criterion("IS_NULL", value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def not_null() -> dict[str, Any]:
|
|
59
|
+
return is_null(False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stash_id(endpoint: str, value: str) -> dict[str, Any]:
|
|
63
|
+
return {"endpoint": endpoint, "stash_id": value}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stash_ids(endpoint: str, values: list[str]) -> dict[str, Any]:
|
|
67
|
+
return {"endpoint": endpoint, "stash_ids": values}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hierarchy(modifier: str, value: Any, **kwargs: Any) -> dict[str, Any]:
|
|
71
|
+
criterion = gql_criterion(modifier, value)
|
|
72
|
+
criterion.update({key: value for key, value in kwargs.items() if value is not None})
|
|
73
|
+
return criterion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions raised by the Stash client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StashError(Exception):
|
|
7
|
+
"""Base class for client errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(StashError):
|
|
11
|
+
"""A network, TLS, or timeout error occurred."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StashConnectionError(TransportError):
|
|
15
|
+
"""The endpoint could not be validated as a Stash GraphQL server."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StashResponseError(StashError):
|
|
19
|
+
"""The server response was not a valid GraphQL response."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GraphQLError(StashError):
|
|
23
|
+
"""The server returned one or more GraphQL errors."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, errors: list[dict[str, Any]], data: Any = None) -> None:
|
|
26
|
+
self.errors = errors
|
|
27
|
+
self.data = data
|
|
28
|
+
message = "; ".join(str(error.get("message", error)) for error in errors)
|
|
29
|
+
super().__init__(message or "GraphQL request failed")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Schema-shaped builders for common Stash filter input objects."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import StashResponseError
|
|
6
|
+
|
|
7
|
+
_FIELDS = {
|
|
8
|
+
"find": {"q", "page", "per_page", "sort", "direction"},
|
|
9
|
+
"scene": {"id", "title", "date", "details", "organized", "rating100", "duration", "tags", "performers", "studios", "performers_filter", "studios_filter", "AND", "OR", "NOT"},
|
|
10
|
+
"image": {"id", "path", "rating100", "organized", "date", "galleries", "studios", "tags", "performers", "AND", "OR", "NOT"},
|
|
11
|
+
"gallery": {"id", "title", "date", "organized", "rating100", "has_chapters", "performers", "studios", "tags", "AND", "OR", "NOT"},
|
|
12
|
+
"performer": {"id", "name", "gender", "country", "rating100", "image_count", "stash_id", "stash_id_endpoint", "AND", "OR", "NOT"},
|
|
13
|
+
"studio": {"id", "name", "url", "favorite", "scene_count", "image_count", "stash_id", "stash_id_endpoint", "AND", "OR", "NOT"},
|
|
14
|
+
"tag": {"id", "name", "favorite", "scene_count", "image_count", "stash_id", "AND", "OR", "NOT"},
|
|
15
|
+
"group": {"id", "name", "rating100", "scene_count", "performers", "studios", "tags", "AND", "OR", "NOT"},
|
|
16
|
+
"marker": {"id", "title", "seconds", "duration", "scene_id", "tags", "AND", "OR", "NOT"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build(kind: str, values: dict[str, Any], *, strict: bool) -> dict[str, Any]:
|
|
21
|
+
unknown = set(values) - _FIELDS[kind]
|
|
22
|
+
if strict and unknown:
|
|
23
|
+
names = ", ".join(sorted(unknown))
|
|
24
|
+
raise StashResponseError(f"unknown fields for {kind}_filter: {names}")
|
|
25
|
+
result = dict(values)
|
|
26
|
+
if kind == "studio" and "stash_id" in result:
|
|
27
|
+
result["stash_id_endpoint"] = result.pop("stash_id")
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
32
|
+
return _build("find", values, strict=strict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scene_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
36
|
+
return _build("scene", values, strict=strict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def image_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
40
|
+
return _build("image", values, strict=strict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def gallery_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
44
|
+
return _build("gallery", values, strict=strict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def performer_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
48
|
+
return _build("performer", values, strict=strict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def studio_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
52
|
+
return _build("studio", values, strict=strict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tag_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
56
|
+
return _build("tag", values, strict=strict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def group_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
60
|
+
return _build("group", values, strict=strict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def marker_filter(*, strict: bool = True, **values: Any) -> dict[str, Any]:
|
|
64
|
+
return _build("marker", values, strict=strict)
|