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.
- src_py_lib/__init__.py +170 -0
- src_py_lib/clients/__init__.py +3 -0
- src_py_lib/clients/github.py +157 -0
- src_py_lib/clients/google_sheets.py +131 -0
- src_py_lib/clients/graphql.py +476 -0
- src_py_lib/clients/linear.py +101 -0
- src_py_lib/clients/one_password.py +95 -0
- src_py_lib/clients/slack.py +146 -0
- src_py_lib/clients/sourcegraph.py +127 -0
- src_py_lib/py.typed +0 -0
- src_py_lib/utils/__init__.py +3 -0
- src_py_lib/utils/config.py +603 -0
- src_py_lib/utils/http.py +279 -0
- src_py_lib/utils/json_cache.py +42 -0
- src_py_lib/utils/json_types.py +54 -0
- src_py_lib/utils/logging.py +950 -0
- src_py_lib/utils/tsv.py +95 -0
- src_py_lib-0.1.0.dist-info/METADATA +163 -0
- src_py_lib-0.1.0.dist-info/RECORD +21 -0
- src_py_lib-0.1.0.dist-info/WHEEL +4 -0
- src_py_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|