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 ADDED
@@ -0,0 +1,170 @@
1
+ """Public interface for src-py-lib consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable, Mapping
7
+ from contextlib import AbstractContextManager
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from src_py_lib.clients.github import GitHubClient, PullRequest, gh_cli_token, pr_ref_from_url
12
+ from src_py_lib.clients.google_sheets import (
13
+ GoogleSheetsClient,
14
+ GoogleSheetsError,
15
+ gcloud_adc_access_token,
16
+ quota_project_from_adc,
17
+ )
18
+ from src_py_lib.clients.graphql import (
19
+ GraphQLClient,
20
+ GraphQLError,
21
+ aliased_batched_query,
22
+ introspect_schema,
23
+ stream_connection_nodes,
24
+ )
25
+ from src_py_lib.clients.linear import (
26
+ LinearClient,
27
+ LinearClientConfig,
28
+ linear_client_from_config,
29
+ )
30
+ from src_py_lib.clients.slack import (
31
+ SlackClient,
32
+ SlackClientConfig,
33
+ SlackError,
34
+ SlackPacer,
35
+ slack_client_from_config,
36
+ )
37
+ from src_py_lib.clients.sourcegraph import (
38
+ SourcegraphClient,
39
+ SourcegraphClientConfig,
40
+ normalize_sourcegraph_endpoint,
41
+ sourcegraph_client_from_config,
42
+ )
43
+ from src_py_lib.utils.config import (
44
+ Config,
45
+ ConfigError,
46
+ config_field,
47
+ config_snapshot,
48
+ )
49
+ from src_py_lib.utils.config import (
50
+ config_parse_args as parse_args,
51
+ )
52
+ from src_py_lib.utils.http import HTTPClient, HTTPClientError
53
+ from src_py_lib.utils.json_cache import load_json_cache, load_json_subset, save_json_cache
54
+ from src_py_lib.utils.json_types import (
55
+ JSONDict,
56
+ json_dict,
57
+ json_dicts,
58
+ json_int,
59
+ json_list,
60
+ json_str,
61
+ json_strs,
62
+ )
63
+ from src_py_lib.utils.logging import (
64
+ LoggingConfig,
65
+ LoggingSettings,
66
+ configure_logging,
67
+ critical,
68
+ debug,
69
+ error,
70
+ event,
71
+ info,
72
+ log,
73
+ log_context,
74
+ logging_context,
75
+ logging_settings_from_config,
76
+ resolve_log_level_name,
77
+ stage,
78
+ startup_event,
79
+ submit_with_log_context,
80
+ warning,
81
+ )
82
+ from src_py_lib.utils.tsv import write_tsv
83
+
84
+
85
+ def logging(
86
+ config: object | None = None,
87
+ *,
88
+ command: str | None = None,
89
+ git_cwd: Path | str | None = None,
90
+ logging_config: LoggingSettings | None = None,
91
+ run_fields: Mapping[str, Any] | None = None,
92
+ run_summary: Callable[[], Mapping[str, Any]] | None = None,
93
+ ) -> AbstractContextManager[Path | None]:
94
+ """Configure standard CLI logging and emit startup metadata."""
95
+ return logging_context(
96
+ command or _script_name(),
97
+ config,
98
+ git_cwd=git_cwd,
99
+ logging_config=logging_config,
100
+ run_fields=run_fields,
101
+ run_summary=run_summary,
102
+ )
103
+
104
+
105
+ def _script_name() -> str:
106
+ return Path(sys.argv[0]).stem or "python"
107
+
108
+
109
+ __all__ = [
110
+ "Config",
111
+ "ConfigError",
112
+ "GraphQLError",
113
+ "GraphQLClient",
114
+ "GitHubClient",
115
+ "GoogleSheetsClient",
116
+ "GoogleSheetsError",
117
+ "HTTPClient",
118
+ "HTTPClientError",
119
+ "JSONDict",
120
+ "LinearClient",
121
+ "LinearClientConfig",
122
+ "LoggingConfig",
123
+ "LoggingSettings",
124
+ "PullRequest",
125
+ "SlackClient",
126
+ "SlackClientConfig",
127
+ "SlackError",
128
+ "SlackPacer",
129
+ "SourcegraphClient",
130
+ "SourcegraphClientConfig",
131
+ "aliased_batched_query",
132
+ "config_field",
133
+ "config_snapshot",
134
+ "configure_logging",
135
+ "critical",
136
+ "debug",
137
+ "error",
138
+ "event",
139
+ "gh_cli_token",
140
+ "gcloud_adc_access_token",
141
+ "info",
142
+ "introspect_schema",
143
+ "json_dict",
144
+ "json_dicts",
145
+ "json_int",
146
+ "json_list",
147
+ "json_str",
148
+ "json_strs",
149
+ "linear_client_from_config",
150
+ "load_json_cache",
151
+ "load_json_subset",
152
+ "logging",
153
+ "logging_settings_from_config",
154
+ "log",
155
+ "log_context",
156
+ "normalize_sourcegraph_endpoint",
157
+ "parse_args",
158
+ "pr_ref_from_url",
159
+ "quota_project_from_adc",
160
+ "resolve_log_level_name",
161
+ "save_json_cache",
162
+ "slack_client_from_config",
163
+ "sourcegraph_client_from_config",
164
+ "stage",
165
+ "startup_event",
166
+ "stream_connection_nodes",
167
+ "submit_with_log_context",
168
+ "warning",
169
+ "write_tsv",
170
+ ]
@@ -0,0 +1,3 @@
1
+ """API clients built on src_py_lib HTTP and logging primitives."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,157 @@
1
+ """GitHub GraphQL API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from typing import TypedDict, cast
10
+ from urllib.parse import urlsplit
11
+
12
+ from src_py_lib.clients.graphql import GraphQLClient, aliased_batched_query
13
+ from src_py_lib.utils.http import HTTPClient
14
+ from src_py_lib.utils.json_types import JSONDict, json_dict, json_str
15
+
16
+ DEFAULT_GITHUB_URL = "https://github.com"
17
+ DEFAULT_PR_BATCH_SIZE = 50
18
+ GITHUB_VALIDATE_QUERY = """
19
+ query GitHubClientValidate {
20
+ viewer {
21
+ login
22
+ }
23
+ }
24
+ """
25
+ PR_REF_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/#]+)#(?P<number>\d+)$")
26
+ PR_URL_RE = re.compile(
27
+ r"https?://[^/\s)>|]+/(?P<owner>[^/\s)>|]+)/(?P<repo>[^/\s)>|]+)/pull/(?P<number>\d+)"
28
+ )
29
+
30
+
31
+ class PullRequest(TypedDict):
32
+ title: str
33
+ url: str
34
+ state: str
35
+ createdAt: str
36
+ mergedAt: str
37
+ closedAt: str
38
+ author: str
39
+
40
+
41
+ @dataclass
42
+ class GitHubClient:
43
+ token: str
44
+ github_url: str = DEFAULT_GITHUB_URL
45
+ http: HTTPClient = field(default_factory=HTTPClient)
46
+
47
+ @classmethod
48
+ def from_gh_cli(
49
+ cls, *, github_url: str = DEFAULT_GITHUB_URL, http: HTTPClient | None = None
50
+ ) -> GitHubClient:
51
+ token = gh_cli_token(github_url=github_url)
52
+ if not token:
53
+ raise RuntimeError("No GitHub token from `gh auth token`.")
54
+ return cls(token=token, github_url=github_url, http=http or HTTPClient())
55
+
56
+ def graphql(self, query: str, variables: JSONDict | None = None) -> JSONDict:
57
+ return GraphQLClient(
58
+ url=graphql_api_url(self.github_url),
59
+ headers={"Authorization": f"bearer {self.token}"},
60
+ label="GitHub",
61
+ http=self.http,
62
+ tolerate_partial_errors=True,
63
+ ).execute(query, variables)
64
+
65
+ def validate(self) -> JSONDict:
66
+ """Validate the token with a cheap viewer query and return the viewer."""
67
+ viewer = json_dict(self.graphql(GITHUB_VALIDATE_QUERY).get("viewer"))
68
+ if not viewer.get("login"):
69
+ raise RuntimeError("GitHub viewer response did not include viewer.login.")
70
+ return viewer
71
+
72
+ def get_pull_requests(
73
+ self, refs: list[str], *, batch_size: int = DEFAULT_PR_BATCH_SIZE
74
+ ) -> dict[str, PullRequest]:
75
+ return cast(
76
+ dict[str, PullRequest],
77
+ aliased_batched_query(
78
+ refs,
79
+ batch_size=batch_size,
80
+ build_alias=_build_pr_alias,
81
+ parse_node=_project_pull_request,
82
+ post=self.graphql,
83
+ ),
84
+ )
85
+
86
+
87
+ def graphql_api_url(github_url: str = DEFAULT_GITHUB_URL) -> str:
88
+ """Return the GraphQL API URL for github.com or a GitHub Enterprise host."""
89
+ normalized = _normalize_github_url(github_url)
90
+ split = urlsplit(normalized)
91
+ if split.hostname == "github.com":
92
+ return f"{split.scheme}://api.github.com/graphql"
93
+ return f"{normalized}/api/graphql"
94
+
95
+
96
+ def gh_cli_token(*, github_url: str = DEFAULT_GITHUB_URL) -> str | None:
97
+ """Return `gh auth token`, or None when gh is unavailable/not logged in."""
98
+ split = urlsplit(_normalize_github_url(github_url))
99
+ command = ["gh", "auth", "token"]
100
+ if split.hostname and split.hostname != "github.com":
101
+ command.extend(["--hostname", split.netloc])
102
+ try:
103
+ result = subprocess.run(command, capture_output=True, text=True, timeout=5, check=False)
104
+ except OSError:
105
+ return None
106
+ except subprocess.SubprocessError:
107
+ return None
108
+ token = result.stdout.strip()
109
+ return token if result.returncode == 0 and token else None
110
+
111
+
112
+ def _normalize_github_url(github_url: str) -> str:
113
+ stripped = github_url.strip().rstrip("/")
114
+ if "://" not in stripped:
115
+ stripped = f"https://{stripped}"
116
+ return stripped
117
+
118
+
119
+ def parse_pr_ref(ref: str) -> tuple[str, str, int]:
120
+ match = PR_REF_RE.match(ref)
121
+ if not match:
122
+ raise ValueError(f"invalid GitHub PR ref: {ref!r}")
123
+ return match.group("owner"), match.group("repo"), int(match.group("number"))
124
+
125
+
126
+ def pr_ref_from_url(url: str) -> str | None:
127
+ match = PR_URL_RE.search(url)
128
+ if not match:
129
+ return None
130
+ return f"{match.group('owner')}/{match.group('repo')}#{match.group('number')}"
131
+
132
+
133
+ def _build_pr_alias(_index: int, ref: str) -> str | None:
134
+ try:
135
+ owner, repo, number = parse_pr_ref(ref)
136
+ except ValueError:
137
+ return None
138
+ return (
139
+ f"repository(owner: {json.dumps(owner)}, name: {json.dumps(repo)}) "
140
+ f"{{ pullRequest(number: {number}) "
141
+ "{ title url state createdAt mergedAt closedAt author { login } } }"
142
+ )
143
+
144
+
145
+ def _project_pull_request(node: JSONDict) -> PullRequest | None:
146
+ pull_request = json_dict(node.get("pullRequest"))
147
+ if not pull_request:
148
+ return None
149
+ return {
150
+ "title": json_str(pull_request, "title"),
151
+ "url": json_str(pull_request, "url"),
152
+ "state": json_str(pull_request, "state"),
153
+ "createdAt": json_str(pull_request, "createdAt"),
154
+ "mergedAt": json_str(pull_request, "mergedAt"),
155
+ "closedAt": json_str(pull_request, "closedAt"),
156
+ "author": json_str(json_dict(pull_request.get("author")), "login"),
157
+ }
@@ -0,0 +1,131 @@
1
+ """Google Sheets API client using an already-available OAuth access token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
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, json_dict, json_int, json_list, json_str
14
+
15
+ SHEETS_API_URL = "https://sheets.googleapis.com/v4/spreadsheets"
16
+ DEFAULT_ADC_FILE = Path("~/.config/gcloud/application_default_credentials.json").expanduser()
17
+
18
+
19
+ class GoogleSheetsError(RuntimeError):
20
+ """Raised for Google Sheets client errors."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class LinkRun:
25
+ start: int
26
+ end: int
27
+ uri: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Cell:
32
+ text: str
33
+ links: tuple[LinkRun, ...] = ()
34
+
35
+
36
+ CellValue = str | Cell
37
+
38
+
39
+ @dataclass
40
+ class GoogleSheetsClient:
41
+ spreadsheet_id: str
42
+ access_token: str
43
+ quota_project: str | None = None
44
+ http: HTTPClient = field(default_factory=HTTPClient)
45
+
46
+ @classmethod
47
+ def from_gcloud_adc(
48
+ cls,
49
+ spreadsheet_id: str,
50
+ *,
51
+ credentials_file: Path = DEFAULT_ADC_FILE,
52
+ http: HTTPClient | None = None,
53
+ ) -> GoogleSheetsClient:
54
+ return cls(
55
+ spreadsheet_id=spreadsheet_id,
56
+ access_token=gcloud_adc_access_token(credentials_file),
57
+ quota_project=quota_project_from_adc(credentials_file),
58
+ http=http or HTTPClient(),
59
+ )
60
+
61
+ def request(self, method: str, path: str, body: JSONDict | None = None) -> JSONDict:
62
+ headers = {"Authorization": f"Bearer {self.access_token}"}
63
+ if self.quota_project:
64
+ headers["X-Goog-User-Project"] = self.quota_project
65
+ try:
66
+ return self.http.json(
67
+ method,
68
+ f"{SHEETS_API_URL}/{self.spreadsheet_id}{path}",
69
+ headers=headers,
70
+ json_body=body,
71
+ )
72
+ except HTTPClientError as exception:
73
+ raise GoogleSheetsError(
74
+ f"Google Sheets {method} {path} failed: {exception}"
75
+ ) from exception
76
+
77
+ def metadata(self) -> JSONDict:
78
+ return self.request("GET", "?fields=sheets.properties(sheetId,title,gridProperties)")
79
+
80
+ def validate(self) -> JSONDict:
81
+ """Validate spreadsheet access and return spreadsheet metadata."""
82
+ metadata = self.metadata()
83
+ if not isinstance(metadata.get("sheets"), list):
84
+ raise GoogleSheetsError("Google Sheets metadata response did not include sheets.")
85
+ return metadata
86
+
87
+ def tab_ids_by_title(self) -> dict[str, int]:
88
+ return {
89
+ json_str(properties, "title"): json_int(properties, "sheetId")
90
+ for sheet in json_list(self.metadata().get("sheets"))
91
+ if (properties := json_dict(json_dict(sheet).get("properties")))
92
+ }
93
+
94
+ def batch_update(self, requests: list[JSONDict]) -> JSONDict:
95
+ return self.request("POST", ":batchUpdate", cast(JSONDict, {"requests": requests}))
96
+
97
+
98
+ def hyperlink_cell(url: str, text: str) -> CellValue:
99
+ if not url:
100
+ return ""
101
+ return Cell(text=text, links=(LinkRun(0, len(text), url),))
102
+
103
+
104
+ def quota_project_from_adc(credentials_file: Path = DEFAULT_ADC_FILE) -> str:
105
+ if not credentials_file.exists():
106
+ raise GoogleSheetsError(f"Application Default Credentials not found at {credentials_file}.")
107
+ data = json_dict(json.loads(credentials_file.read_text(encoding="utf-8")))
108
+ quota_project = json_str(data, "quota_project_id")
109
+ if not quota_project:
110
+ raise GoogleSheetsError(f"{credentials_file} does not contain quota_project_id.")
111
+ return quota_project
112
+
113
+
114
+ def gcloud_adc_access_token(credentials_file: Path = DEFAULT_ADC_FILE) -> str:
115
+ env = os.environ.copy()
116
+ env["GOOGLE_APPLICATION_CREDENTIALS"] = str(credentials_file)
117
+ try:
118
+ result = subprocess.run(
119
+ ["gcloud", "auth", "application-default", "print-access-token"],
120
+ env=env,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ check=False,
125
+ )
126
+ except (OSError, subprocess.SubprocessError) as exception:
127
+ raise GoogleSheetsError("Could not run gcloud to fetch an ADC access token.") from exception
128
+ token = result.stdout.strip()
129
+ if result.returncode != 0 or not token:
130
+ raise GoogleSheetsError(result.stderr.strip() or "gcloud did not return an access token.")
131
+ return token