src-py-lib 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.
@@ -0,0 +1,476 @@
1
+ """Shared GraphQL client primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable, Iterator, Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import cast
11
+
12
+ from src_py_lib.utils.http import HTTPClient, HTTPClientError
13
+ from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list, json_str
14
+ from src_py_lib.utils.logging import event
15
+
16
+ _OPERATION_NAME_RE = re.compile(r"\b(?:query|mutation|subscription)\s+(\w+)")
17
+
18
+ GRAPHQL_INTROSPECTION_QUERY = """
19
+ query IntrospectionQuery {
20
+ __schema {
21
+ queryType { name }
22
+ mutationType { name }
23
+ subscriptionType { name }
24
+ types {
25
+ ...FullType
26
+ }
27
+ directives {
28
+ name
29
+ description
30
+ locations
31
+ args {
32
+ ...InputValue
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ fragment FullType on __Type {
39
+ kind
40
+ name
41
+ description
42
+ fields(includeDeprecated: true) {
43
+ name
44
+ description
45
+ args {
46
+ ...InputValue
47
+ }
48
+ type {
49
+ ...TypeRef
50
+ }
51
+ isDeprecated
52
+ deprecationReason
53
+ }
54
+ inputFields {
55
+ ...InputValue
56
+ }
57
+ interfaces {
58
+ ...TypeRef
59
+ }
60
+ enumValues(includeDeprecated: true) {
61
+ name
62
+ description
63
+ isDeprecated
64
+ deprecationReason
65
+ }
66
+ possibleTypes {
67
+ ...TypeRef
68
+ }
69
+ }
70
+
71
+ fragment InputValue on __InputValue {
72
+ name
73
+ description
74
+ type { ...TypeRef }
75
+ defaultValue
76
+ }
77
+
78
+ fragment TypeRef on __Type {
79
+ kind
80
+ name
81
+ ofType {
82
+ kind
83
+ name
84
+ ofType {
85
+ kind
86
+ name
87
+ ofType {
88
+ kind
89
+ name
90
+ ofType {
91
+ kind
92
+ name
93
+ ofType {
94
+ kind
95
+ name
96
+ ofType {
97
+ kind
98
+ name
99
+ ofType {
100
+ kind
101
+ name
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ """.strip()
111
+
112
+
113
+ class GraphQLError(RuntimeError):
114
+ """Raised for GraphQL transport or application errors."""
115
+
116
+ def __init__(
117
+ self,
118
+ message: str,
119
+ *,
120
+ status_code: int | None = None,
121
+ is_application_error: bool = False,
122
+ ) -> None:
123
+ super().__init__(message)
124
+ self.status_code = status_code
125
+ self.is_application_error = is_application_error
126
+
127
+
128
+ @dataclass
129
+ class GraphQLClient:
130
+ """POST JSON GraphQL operations and return the `data` object."""
131
+
132
+ url: str
133
+ headers: dict[str, str]
134
+ label: str
135
+ http: HTTPClient = field(default_factory=HTTPClient)
136
+ tolerate_partial_errors: bool = False
137
+
138
+ def execute(
139
+ self,
140
+ query: str,
141
+ variables: Mapping[str, JSONValue] | None = None,
142
+ *,
143
+ follow_pages: bool = True,
144
+ page_size: int | None = None,
145
+ first_variable: str = "first",
146
+ after_variable: str = "after",
147
+ ) -> JSONDict:
148
+ page_variables: JSONDict = dict(variables) if variables is not None else {}
149
+ if page_size is not None:
150
+ page_variables[first_variable] = page_size
151
+ if (
152
+ follow_pages
153
+ and after_variable not in page_variables
154
+ and _query_uses_variable(query, after_variable)
155
+ ):
156
+ page_variables[after_variable] = None
157
+
158
+ page_number = 1
159
+ data = self._execute_once(
160
+ query,
161
+ page_variables,
162
+ page_number=page_number,
163
+ first_variable=first_variable,
164
+ after_variable=after_variable,
165
+ )
166
+ if follow_pages:
167
+
168
+ def execute_next_page(next_variables: JSONDict) -> JSONDict:
169
+ nonlocal page_number
170
+ page_number += 1
171
+ return self._execute_once(
172
+ query,
173
+ next_variables,
174
+ page_number=page_number,
175
+ first_variable=first_variable,
176
+ after_variable=after_variable,
177
+ )
178
+
179
+ _fetch_remaining_pages(
180
+ execute_next_page,
181
+ data,
182
+ page_variables,
183
+ after_variable=after_variable,
184
+ query_uses_after_variable=_query_uses_variable(query, after_variable),
185
+ )
186
+ return data
187
+
188
+ def stream_connection_nodes(
189
+ self,
190
+ query: str,
191
+ variables: Mapping[str, JSONValue] | None = None,
192
+ *,
193
+ connection_path: Sequence[str],
194
+ page_size: int | None = None,
195
+ first_variable: str = "first",
196
+ after_variable: str = "after",
197
+ ) -> Iterator[JSONDict]:
198
+ """Stream one GraphQL connection's nodes page by page.
199
+
200
+ `connection_path` is the response path to the connection object that
201
+ contains `nodes` and `pageInfo`, for example `("viewer", "items")`.
202
+ Unlike `execute(..., follow_pages=True)`, this does not accumulate all
203
+ nodes in memory before returning.
204
+ """
205
+ page_number = 1
206
+
207
+ def execute_page(
208
+ operation: str, page_variables: Mapping[str, JSONValue] | None
209
+ ) -> JSONDict:
210
+ nonlocal page_number
211
+ data = self._execute_once(
212
+ operation,
213
+ dict(page_variables or {}),
214
+ page_number=page_number,
215
+ first_variable=first_variable,
216
+ after_variable=after_variable,
217
+ )
218
+ page_number += 1
219
+ return data
220
+
221
+ yield from stream_connection_nodes(
222
+ execute_page,
223
+ query,
224
+ variables,
225
+ connection_path=connection_path,
226
+ page_size=page_size,
227
+ first_variable=first_variable,
228
+ after_variable=after_variable,
229
+ )
230
+
231
+ def _execute_once(
232
+ self,
233
+ query: str,
234
+ variables: JSONDict,
235
+ *,
236
+ page_number: int = 1,
237
+ first_variable: str = "first",
238
+ after_variable: str = "after",
239
+ ) -> JSONDict:
240
+ body = {"query": query, "variables": variables or {}}
241
+ with event(
242
+ "graphql_query",
243
+ level="debug",
244
+ graphql_client=self.label,
245
+ query_name=operation_name(query),
246
+ page_number=page_number,
247
+ page_size=_int_variable(variables, first_variable),
248
+ cursor_present=variables.get(after_variable) is not None,
249
+ url=self.url,
250
+ variable_names=sorted(variables),
251
+ query_bytes=len(query.encode("utf-8")),
252
+ ) as fields:
253
+ try:
254
+ payload = self.http.json("POST", self.url, headers=self.headers, json_body=body)
255
+ except HTTPClientError as exception:
256
+ raise GraphQLError(
257
+ f"{self.label} GraphQL request failed: {exception}",
258
+ status_code=exception.status_code,
259
+ ) from exception
260
+ errors = payload.get("errors")
261
+ data = json_dict(payload.get("data"))
262
+ fields["response_fields"] = sorted(data)
263
+ if errors:
264
+ fields["graphql_errors"] = len(errors) if isinstance(errors, list) else 1
265
+ if errors and not (self.tolerate_partial_errors and data):
266
+ raise GraphQLError(
267
+ f"{self.label} GraphQL errors: {errors}",
268
+ is_application_error=True,
269
+ )
270
+ return data
271
+
272
+
273
+ def operation_name(query: str) -> str:
274
+ """Extract the operation name from a GraphQL document."""
275
+ match = _OPERATION_NAME_RE.search(query)
276
+ return match.group(1) if match else "anonymous"
277
+
278
+
279
+ def stream_connection_nodes(
280
+ execute: Callable[[str, Mapping[str, JSONValue] | None], JSONDict],
281
+ query: str,
282
+ variables: Mapping[str, JSONValue] | None = None,
283
+ *,
284
+ connection_path: Sequence[str],
285
+ page_size: int | None = None,
286
+ first_variable: str = "first",
287
+ after_variable: str = "after",
288
+ ) -> Iterator[JSONDict]:
289
+ """Stream one GraphQL connection's nodes through any execute callable."""
290
+ page_variables: JSONDict = dict(variables) if variables is not None else {}
291
+ if page_size is not None:
292
+ page_variables[first_variable] = page_size
293
+ query_uses_after_variable = _query_uses_variable(query, after_variable)
294
+ if query_uses_after_variable and after_variable not in page_variables:
295
+ page_variables[after_variable] = None
296
+
297
+ path = tuple(connection_path)
298
+ current_cursor = page_variables.get(after_variable)
299
+ while True:
300
+ data = execute(query, dict(page_variables))
301
+ page = _node_page_at_path(data, path)
302
+ for node in json_list(page.get("nodes")):
303
+ yield json_dict(node)
304
+
305
+ page_info = json_dict(page.get("pageInfo"))
306
+ has_next_page = page_info.get("hasNextPage")
307
+ if not isinstance(has_next_page, bool):
308
+ raise GraphQLError(
309
+ f"GraphQL pagination path {_path_label(path)} missing pageInfo.hasNextPage"
310
+ )
311
+ if not has_next_page:
312
+ return
313
+ if not query_uses_after_variable:
314
+ raise GraphQLError(
315
+ f"GraphQL query returned more pages but does not use ${after_variable}"
316
+ )
317
+ next_cursor = _next_page_cursor(page_info, path, current_cursor)
318
+ page_variables[after_variable] = next_cursor
319
+ current_cursor = next_cursor
320
+
321
+
322
+ def _int_variable(variables: JSONDict, name: str) -> int | None:
323
+ value = variables.get(name)
324
+ return value if isinstance(value, int) else None
325
+
326
+
327
+ def introspect_schema(
328
+ client_or_execute: GraphQLClient | Callable[[str], JSONDict],
329
+ *,
330
+ output_file: Path | str | None = None,
331
+ ) -> JSONDict | None:
332
+ """Fetch a GraphQL introspection schema or write it to `output_file`.
333
+
334
+ Pass either a `GraphQLClient` or a callable such as `SourcegraphClient.graphql`.
335
+ When `output_file` is supplied, the schema JSON is written there and `None` is
336
+ returned. Otherwise, the introspection `__schema` object is returned.
337
+ """
338
+ if isinstance(client_or_execute, GraphQLClient):
339
+ data = client_or_execute.execute(GRAPHQL_INTROSPECTION_QUERY, follow_pages=False)
340
+ else:
341
+ data = client_or_execute(GRAPHQL_INTROSPECTION_QUERY)
342
+ schema = json_dict(data.get("__schema"))
343
+ if not schema:
344
+ raise GraphQLError("GraphQL introspection response did not include __schema.")
345
+ if output_file is None:
346
+ return schema
347
+
348
+ path = Path(output_file)
349
+ path.parent.mkdir(parents=True, exist_ok=True)
350
+ path.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8")
351
+ return None
352
+
353
+
354
+ def aliased_batched_query(
355
+ keys: list[str],
356
+ *,
357
+ batch_size: int,
358
+ build_alias: Callable[[int, str], str | None],
359
+ parse_node: Callable[[JSONDict], object | None],
360
+ post: Callable[[str], JSONDict],
361
+ ) -> dict[str, object]:
362
+ """Look up many keys with GraphQL aliases in fixed-size batches."""
363
+ results: dict[str, object] = {}
364
+ for chunk_start in range(0, len(keys), batch_size):
365
+ chunk = keys[chunk_start : chunk_start + batch_size]
366
+ parts: list[str] = []
367
+ for index, key in enumerate(chunk):
368
+ alias = build_alias(index, key)
369
+ if alias is not None:
370
+ parts.append(f"q{index}: {alias}")
371
+ if not parts:
372
+ continue
373
+ data = post("query { " + " ".join(parts) + " }")
374
+ for index, key in enumerate(chunk):
375
+ node = json_dict(data.get(f"q{index}"))
376
+ if not node:
377
+ continue
378
+ value = parse_node(node)
379
+ if value is not None:
380
+ results[key] = value
381
+ return results
382
+
383
+
384
+ def _fetch_remaining_pages(
385
+ execute: Callable[[JSONDict], JSONDict],
386
+ data: JSONDict,
387
+ variables: JSONDict,
388
+ *,
389
+ after_variable: str,
390
+ query_uses_after_variable: bool,
391
+ ) -> None:
392
+ paths = _next_page_paths(data)
393
+ if not paths:
394
+ return
395
+ if len(paths) > 1:
396
+ joined = ", ".join(".".join(path) for path in paths)
397
+ raise GraphQLError(f"GraphQL query returned multiple paginated node lists: {joined}")
398
+ if not query_uses_after_variable:
399
+ raise GraphQLError(f"GraphQL query returned more pages but does not use ${after_variable}")
400
+
401
+ path = paths[0]
402
+ target_page = _node_page_at_path(data, path)
403
+ target_nodes = json_list(target_page.get("nodes"))
404
+ page_info = json_dict(target_page.get("pageInfo"))
405
+ after = _next_page_cursor(page_info, path, variables.get(after_variable))
406
+
407
+ while after:
408
+ page_variables = dict(variables)
409
+ page_variables[after_variable] = after
410
+ next_data = execute(page_variables)
411
+ next_page = _node_page_at_path(next_data, path)
412
+ target_nodes.extend(json_list(next_page.get("nodes")))
413
+ target_page["nodes"] = target_nodes
414
+ target_page["pageInfo"] = next_page.get("pageInfo")
415
+
416
+ next_page_info = json_dict(next_page.get("pageInfo"))
417
+ has_next_page = next_page_info.get("hasNextPage")
418
+ if not isinstance(has_next_page, bool):
419
+ raise GraphQLError(
420
+ f"GraphQL pagination path {'.'.join(path)} missing pageInfo.hasNextPage"
421
+ )
422
+ if not has_next_page:
423
+ return
424
+ after = _next_page_cursor(next_page_info, path, after)
425
+
426
+
427
+ def _next_page_paths(data: JSONDict) -> list[tuple[str, ...]]:
428
+ paths: list[tuple[str, ...]] = []
429
+
430
+ def visit(value: object, path: tuple[str, ...]) -> None:
431
+ if isinstance(value, dict):
432
+ mapping = cast(JSONDict, value)
433
+ page_info = json_dict(mapping.get("pageInfo"))
434
+ if isinstance(mapping.get("nodes"), list) and page_info.get("hasNextPage") is True:
435
+ paths.append(path)
436
+ return
437
+ for key, child in mapping.items():
438
+ visit(child, (*path, key))
439
+ elif isinstance(value, list):
440
+ for child in cast(list[object], value):
441
+ visit(child, path)
442
+
443
+ visit(data, ())
444
+ return paths
445
+
446
+
447
+ def _node_page_at_path(data: JSONDict, path: tuple[str, ...]) -> JSONDict:
448
+ current: object = data
449
+ for key in path:
450
+ current = json_dict(current).get(key)
451
+ page = json_dict(current)
452
+ if not page:
453
+ raise GraphQLError(f"GraphQL response did not include pagination path {_path_label(path)}")
454
+ return page
455
+
456
+
457
+ def _next_page_cursor(page_info: JSONDict, path: tuple[str, ...], current_cursor: object) -> str:
458
+ next_cursor = json_str(page_info, "endCursor")
459
+ if not next_cursor:
460
+ raise GraphQLError(
461
+ f"GraphQL pagination path {_path_label(path)} missing pageInfo.endCursor"
462
+ )
463
+ if isinstance(current_cursor, str) and next_cursor == current_cursor:
464
+ raise GraphQLError(
465
+ f"GraphQL pagination path {_path_label(path)} stalled: "
466
+ f"pageInfo.endCursor did not advance from {current_cursor!r}"
467
+ )
468
+ return next_cursor
469
+
470
+
471
+ def _path_label(path: tuple[str, ...]) -> str:
472
+ return ".".join(path) or "<root>"
473
+
474
+
475
+ def _query_uses_variable(query: str, variable: str) -> bool:
476
+ return re.search(rf"\${re.escape(variable)}\b", query) is not None
@@ -0,0 +1,101 @@
1
+ """Linear GraphQL API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+
8
+ from src_py_lib.clients.graphql import GraphQLClient
9
+ from src_py_lib.utils.config import Config, config_field
10
+ from src_py_lib.utils.http import HTTPClient
11
+ from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_dicts
12
+
13
+ LINEAR_API_URL = "https://api.linear.app/graphql"
14
+ LINEAR_VALIDATE_QUERY = """
15
+ query LinearClientValidate {
16
+ viewer {
17
+ email
18
+ }
19
+ }
20
+ """
21
+ LINEAR_USERS_QUERY = """
22
+ query LinearUsers($first: Int!, $after: String) {
23
+ users(first: $first, after: $after, includeArchived: true) {
24
+ nodes {
25
+ id
26
+ name
27
+ displayName
28
+ email
29
+ teamMemberships(first: 25) {
30
+ nodes {
31
+ team {
32
+ id
33
+ key
34
+ name
35
+ }
36
+ }
37
+ }
38
+ }
39
+ pageInfo {
40
+ hasNextPage
41
+ endCursor
42
+ }
43
+ }
44
+ }
45
+ """
46
+
47
+
48
+ class LinearClientConfig(Config):
49
+ """Config fields needed to build a Linear API client."""
50
+
51
+ linear_api_token: str = config_field(
52
+ default="",
53
+ env_var="LINEAR_API_TOKEN",
54
+ cli_flag="--linear-api-token",
55
+ metavar="TOKEN",
56
+ help="Linear API token or op:// secret reference",
57
+ secret=True,
58
+ required=True,
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class LinearClient:
64
+ token: str
65
+ http: HTTPClient = field(default_factory=HTTPClient)
66
+
67
+ def graphql(
68
+ self,
69
+ query: str,
70
+ variables: Mapping[str, JSONValue] | None = None,
71
+ *,
72
+ page_size: int | None = None,
73
+ ) -> JSONDict:
74
+
75
+ return GraphQLClient(
76
+ url=LINEAR_API_URL,
77
+ headers={"Authorization": self.token},
78
+ label="Linear",
79
+ http=self.http,
80
+ ).execute(query, variables=variables, page_size=page_size)
81
+
82
+ def validate(self) -> JSONDict:
83
+ """Validate the token with a cheap viewer query and return the viewer."""
84
+ viewer = json_dict(self.graphql(LINEAR_VALIDATE_QUERY).get("viewer"))
85
+ if not viewer.get("email"):
86
+ raise RuntimeError("Linear viewer response did not include viewer.email.")
87
+ return viewer
88
+
89
+ def list_users(self, *, page_size: int = 100) -> list[JSONDict]:
90
+ """Return every Linear user with common people-directory fields."""
91
+ data = self.graphql(LINEAR_USERS_QUERY, page_size=page_size)
92
+ return json_dicts(json_dict(data.get("users")).get("nodes"))
93
+
94
+
95
+ def linear_client_from_config(
96
+ config: LinearClientConfig, *, http: HTTPClient | None = None
97
+ ) -> LinearClient:
98
+ """Return a Linear API client from shared Linear Config fields."""
99
+ if http is None:
100
+ return LinearClient(config.linear_api_token)
101
+ return LinearClient(config.linear_api_token, http=http)
@@ -0,0 +1,95 @@
1
+ """Small 1Password CLI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+
9
+ from src_py_lib.utils.json_types import JSONDict, json_dict
10
+
11
+
12
+ class OnePasswordError(RuntimeError):
13
+ """Raised when resolving a 1Password reference fails."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class OnePasswordClient:
18
+ """Resolve `op://...` references through the `op` CLI."""
19
+
20
+ op_binary: str = "op"
21
+
22
+ def signin(self) -> JSONDict:
23
+ """Run an interactive 1Password CLI sign-in, then return account info."""
24
+ try:
25
+ subprocess.run([self.op_binary, "signin"], check=True)
26
+ except FileNotFoundError as exception:
27
+ raise OnePasswordError("1Password CLI (`op`) was not found on PATH.") from exception
28
+ except subprocess.CalledProcessError as exception:
29
+ stderr = exception.stderr.strip() if isinstance(exception.stderr, str) else ""
30
+ raise OnePasswordError(
31
+ f"Failed to sign in to 1Password CLI (`op`): {stderr or exception}"
32
+ ) from exception
33
+
34
+ return self.validate()
35
+
36
+ def validate(self) -> JSONDict:
37
+ """Validate that the 1Password CLI is authenticated and return account info."""
38
+ try:
39
+ result = subprocess.run(
40
+ [self.op_binary, "whoami", "--format", "json"],
41
+ check=True,
42
+ text=True,
43
+ capture_output=True,
44
+ )
45
+ except FileNotFoundError as exception:
46
+ raise OnePasswordError("1Password CLI (`op`) was not found on PATH.") from exception
47
+ except subprocess.CalledProcessError as exception:
48
+ stderr = exception.stderr.strip()
49
+ raise OnePasswordError(
50
+ f"1Password CLI (`op`) is not authenticated: {stderr or exception}"
51
+ ) from exception
52
+
53
+ try:
54
+ account = json_dict(json.loads(result.stdout))
55
+ except json.JSONDecodeError as exception:
56
+ raise OnePasswordError("`op whoami --format json` returned invalid JSON") from exception
57
+ if not account:
58
+ raise OnePasswordError("`op whoami --format json` returned an empty account")
59
+ return account
60
+
61
+ def read(self, secret_ref: str) -> str:
62
+ """Return the resolved value for one `op://...` reference."""
63
+ try:
64
+ result = subprocess.run(
65
+ [self.op_binary, "read", secret_ref],
66
+ check=True,
67
+ text=True,
68
+ capture_output=True,
69
+ )
70
+ except FileNotFoundError as exception:
71
+ raise OnePasswordError("1Password CLI (`op`) was not found on PATH.") from exception
72
+ except subprocess.CalledProcessError as exception:
73
+ stderr = exception.stderr.strip()
74
+ raise OnePasswordError(
75
+ f"Failed to resolve 1Password reference: {stderr or exception}"
76
+ ) from exception
77
+
78
+ secret = result.stdout.strip()
79
+ if not secret:
80
+ raise OnePasswordError(f"`op read {secret_ref}` returned an empty value")
81
+ return secret
82
+
83
+
84
+ def resolve_op_secret_ref(value: str, *, client: OnePasswordClient | None = None) -> str:
85
+ """Resolve `value` if it is an `op://...` reference; otherwise return it.
86
+
87
+ This is useful for config values that may be either a raw value or a
88
+ 1Password reference. The resolved value is returned, not logged.
89
+ """
90
+ stripped = value.strip()
91
+ if not stripped:
92
+ raise OnePasswordError("1Password reference value is empty")
93
+ if not stripped.startswith("op://"):
94
+ return stripped
95
+ return (client or OnePasswordClient()).read(stripped)