Skip to content

Commit 010e8e3

Browse files
Share JSON cache and Linear user helpers
Amp-Thread-ID: https://ampcode.com/threads/T-019e497d-a5a0-77b0-a8bb-fa8abc74113a Co-authored-by: Amp <amp@ampcode.com>
1 parent 7a2c270 commit 010e8e3

5 files changed

Lines changed: 142 additions & 2 deletions

File tree

src/src_py_lib/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from pathlib import Path
88

99
from src_py_lib.clients.github import GitHubClient, PullRequest, gh_cli_token, pr_ref_from_url
10-
from src_py_lib.clients.google_sheets import GoogleSheetsClient, GoogleSheetsError
10+
from src_py_lib.clients.google_sheets import (
11+
GoogleSheetsClient,
12+
GoogleSheetsError,
13+
gcloud_adc_access_token,
14+
quota_project_from_adc,
15+
)
1116
from src_py_lib.clients.graphql import (
1217
GraphQLClient,
1318
GraphQLError,
@@ -36,6 +41,7 @@
3641
config_parse_args as parse_args,
3742
)
3843
from src_py_lib.utils.http import HTTPClient, HTTPClientError
44+
from src_py_lib.utils.json_cache import load_json_cache, load_json_subset, save_json_cache
3945
from src_py_lib.utils.json_types import (
4046
JSONDict,
4147
json_dict,
@@ -112,6 +118,7 @@ def _script_name() -> str:
112118
"error",
113119
"event",
114120
"gh_cli_token",
121+
"gcloud_adc_access_token",
115122
"info",
116123
"introspect_schema",
117124
"json_dict",
@@ -121,11 +128,15 @@ def _script_name() -> str:
121128
"json_str",
122129
"json_strs",
123130
"linear_client_from_config",
131+
"load_json_cache",
132+
"load_json_subset",
124133
"logging",
125134
"log",
126135
"log_context",
127136
"parse_args",
128137
"pr_ref_from_url",
138+
"quota_project_from_adc",
139+
"save_json_cache",
129140
"slack_client_from_config",
130141
"startup_event",
131142
"warning",

src/src_py_lib/clients/linear.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from src_py_lib.clients.graphql import GraphQLClient
99
from src_py_lib.utils.config import Config, config_field
1010
from src_py_lib.utils.http import HTTPClient
11-
from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict
11+
from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_dicts
1212

1313
LINEAR_API_URL = "https://api.linear.app/graphql"
1414
LINEAR_VALIDATE_QUERY = """
@@ -18,6 +18,31 @@
1818
}
1919
}
2020
"""
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+
"""
2146

2247

2348
class LinearClientConfig(Config):
@@ -61,6 +86,11 @@ def validate(self) -> JSONDict:
6186
raise RuntimeError("Linear viewer response did not include viewer.email.")
6287
return viewer
6388

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+
6494

6595
def linear_client_from_config(
6696
config: LinearClientConfig, *, http: HTTPClient | None = None

src/src_py_lib/utils/json_cache.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Small on-disk JSON cache helpers."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from collections.abc import Callable, Mapping
7+
from pathlib import Path
8+
from typing import cast
9+
10+
from src_py_lib.utils.json_types import JSONDict
11+
12+
13+
def load_json_cache[Entry](
14+
path: Path,
15+
parse: Callable[[JSONDict], Entry] | None = None,
16+
) -> dict[str, Entry]:
17+
"""Load `path` as a string-keyed cache. Missing files return `{}`."""
18+
if not path.exists():
19+
return {}
20+
raw = cast(dict[str, JSONDict], json.loads(path.read_text(encoding="utf-8")))
21+
if parse is None:
22+
return cast(dict[str, Entry], raw)
23+
return {key: parse(value) for key, value in raw.items()}
24+
25+
26+
def save_json_cache(path: Path, cache: Mapping[str, object]) -> None:
27+
"""Write a string-keyed JSON cache with stable formatting."""
28+
path.parent.mkdir(parents=True, exist_ok=True)
29+
path.write_text(json.dumps(dict(cache), indent=2, sort_keys=True) + "\n", encoding="utf-8")
30+
31+
32+
def load_json_subset[Entry](
33+
path: Path,
34+
keys: list[str],
35+
parse: Callable[[JSONDict], Entry] | None = None,
36+
) -> dict[str, Entry]:
37+
"""Load only `keys` that are present in a string-keyed JSON cache."""
38+
cache = load_json_cache(path, parse=parse)
39+
return {key: cache[key] for key in keys if key in cache}
40+
41+
42+
__all__ = ["load_json_cache", "load_json_subset", "save_json_cache"]

tests/test_import.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ def test_root_public_api_exports_common_entrypoints(self) -> None:
2525
self.assertIsNotNone(src_py_lib.SlackPacer)
2626
self.assertIsNotNone(src_py_lib.config_field)
2727
self.assertIsNotNone(src_py_lib.gh_cli_token)
28+
self.assertIsNotNone(src_py_lib.gcloud_adc_access_token)
2829
self.assertIsNotNone(src_py_lib.info)
2930
self.assertIsNotNone(src_py_lib.json_dicts)
3031
self.assertIsNotNone(src_py_lib.json_str)
3132
self.assertIsNotNone(src_py_lib.log)
3233
self.assertIsNotNone(src_py_lib.logging)
3334
self.assertIsNotNone(src_py_lib.linear_client_from_config)
35+
self.assertIsNotNone(src_py_lib.load_json_cache)
3436
self.assertIsNotNone(src_py_lib.parse_args)
37+
self.assertIsNotNone(src_py_lib.quota_project_from_adc)
38+
self.assertIsNotNone(src_py_lib.save_json_cache)
3539
self.assertIsNotNone(src_py_lib.slack_client_from_config)
3640
self.assertIsNotNone(src_py_lib.write_tsv)
3741

tests/test_logging_http_clients.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,59 @@ def test_linear_client_validate_requires_viewer_email(self) -> None:
12831283
with self.assertRaisesRegex(RuntimeError, "viewer.email"):
12841284
client.validate()
12851285

1286+
def test_linear_client_list_users_paginates(self) -> None:
1287+
http = RecordingHTTP(
1288+
[
1289+
{
1290+
"data": {
1291+
"users": {
1292+
"nodes": [{"id": "U1", "name": "Alice"}],
1293+
"pageInfo": {
1294+
"hasNextPage": True,
1295+
"endCursor": "cursor-1",
1296+
},
1297+
}
1298+
}
1299+
},
1300+
{
1301+
"data": {
1302+
"users": {
1303+
"nodes": [{"id": "U2", "name": "Bob"}],
1304+
"pageInfo": {
1305+
"hasNextPage": False,
1306+
"endCursor": None,
1307+
},
1308+
}
1309+
}
1310+
},
1311+
]
1312+
)
1313+
1314+
users = LinearClient("token", http=http).list_users(page_size=25)
1315+
1316+
self.assertEqual([user["id"] for user in users], ["U1", "U2"])
1317+
first_body = json_dict(http.calls[0]["json_body"])
1318+
second_body = json_dict(http.calls[1]["json_body"])
1319+
self.assertEqual(
1320+
json_dict(first_body.get("variables")),
1321+
{"first": 25, "after": None},
1322+
)
1323+
self.assertEqual(
1324+
json_dict(second_body.get("variables")),
1325+
{"first": 25, "after": "cursor-1"},
1326+
)
1327+
1328+
def test_json_cache_helpers_round_trip_and_parse(self) -> None:
1329+
with tempfile.TemporaryDirectory() as tmp:
1330+
path = Path(tmp) / "nested" / "cache.json"
1331+
1332+
src.save_json_cache(path, {"b": {"name": "Bob"}, "a": {"name": "Alice"}})
1333+
parsed = src.load_json_cache(path, parse=lambda value: str(value.get("name", "")))
1334+
subset = src.load_json_subset(path, ["a", "missing"], parse=lambda value: value)
1335+
1336+
self.assertEqual(parsed, {"a": "Alice", "b": "Bob"})
1337+
self.assertEqual(subset, {"a": {"name": "Alice"}})
1338+
12861339
def test_resolve_op_secret_ref_leaves_raw_values_alone(self) -> None:
12871340
self.assertEqual(resolve_op_secret_ref(" raw-secret "), "raw-secret")
12881341

0 commit comments

Comments
 (0)