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.
@@ -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)