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,146 @@
|
|
|
1
|
+
"""Slack Web API client with cursor pagination and rate-limit handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Final, cast
|
|
10
|
+
|
|
11
|
+
from src_py_lib.utils.config import Config, config_field
|
|
12
|
+
from src_py_lib.utils.http import HTTPClient, HTTPClientError, retry_after_seconds
|
|
13
|
+
from src_py_lib.utils.json_types import JSONDict, json_dict, json_list, json_str
|
|
14
|
+
|
|
15
|
+
SLACK_API_URL: Final[str] = "https://slack.com/api"
|
|
16
|
+
DEFAULT_PAGE_LIMIT: Final[int] = 200
|
|
17
|
+
DEFAULT_METHOD_INTERVAL_SECONDS: Final[float] = 1.3
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SlackError(RuntimeError):
|
|
23
|
+
"""Raised for Slack API errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SlackClientConfig(Config):
|
|
27
|
+
"""Config fields needed to build a Slack API client."""
|
|
28
|
+
|
|
29
|
+
slack_bot_token: str = config_field(
|
|
30
|
+
default="",
|
|
31
|
+
env_var="SLACK_BOT_TOKEN",
|
|
32
|
+
cli_flag="--slack-bot-token",
|
|
33
|
+
metavar="TOKEN",
|
|
34
|
+
help="Slack bot token or op:// secret reference",
|
|
35
|
+
secret=True,
|
|
36
|
+
required=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SlackPacer:
|
|
42
|
+
"""Reserve spaced request slots per Slack method to avoid 429 bursts."""
|
|
43
|
+
|
|
44
|
+
default_interval_seconds: float = DEFAULT_METHOD_INTERVAL_SECONDS
|
|
45
|
+
method_intervals: dict[str, float] = field(default_factory=lambda: cast(dict[str, float], {}))
|
|
46
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
47
|
+
_next_slot: dict[str, float] = field(
|
|
48
|
+
default_factory=lambda: cast(dict[str, float], {}), init=False
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def wait_for_slot(self, method: str) -> None:
|
|
52
|
+
interval = self.method_intervals.get(method, self.default_interval_seconds)
|
|
53
|
+
with self._lock:
|
|
54
|
+
now = time.time()
|
|
55
|
+
slot = max(self._next_slot.get(method, 0.0), now)
|
|
56
|
+
self._next_slot[method] = slot + interval
|
|
57
|
+
delay = slot - time.time()
|
|
58
|
+
if delay > 0:
|
|
59
|
+
time.sleep(delay)
|
|
60
|
+
|
|
61
|
+
def bump_after_rate_limit(self, method: str, wait_seconds: float) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._next_slot[method] = max(
|
|
64
|
+
self._next_slot.get(method, 0.0), time.time() + wait_seconds
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SlackClient:
|
|
70
|
+
token: str
|
|
71
|
+
http: HTTPClient = field(default_factory=lambda: HTTPClient(max_attempts=1))
|
|
72
|
+
pacer: SlackPacer = field(default_factory=SlackPacer)
|
|
73
|
+
|
|
74
|
+
def get(self, method: str, params: dict[str, Any] | None = None) -> JSONDict:
|
|
75
|
+
while True:
|
|
76
|
+
self.pacer.wait_for_slot(method)
|
|
77
|
+
try:
|
|
78
|
+
data = self.http.json(
|
|
79
|
+
"GET",
|
|
80
|
+
f"{SLACK_API_URL}/{method}",
|
|
81
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
82
|
+
query=params or {},
|
|
83
|
+
)
|
|
84
|
+
except HTTPClientError as exception:
|
|
85
|
+
if exception.status_code == 429:
|
|
86
|
+
wait_seconds = retry_after_seconds(exception.headers.get("retry-after")) or 5.0
|
|
87
|
+
logger.warning("Slack %s rate-limited; sleeping %.0fs.", method, wait_seconds)
|
|
88
|
+
self.pacer.bump_after_rate_limit(method, wait_seconds)
|
|
89
|
+
continue
|
|
90
|
+
raise SlackError(f"Slack request to {method} failed: {exception}") from exception
|
|
91
|
+
if data.get("ok") is True:
|
|
92
|
+
return data
|
|
93
|
+
if data.get("error") == "ratelimited":
|
|
94
|
+
self.pacer.bump_after_rate_limit(method, 5)
|
|
95
|
+
continue
|
|
96
|
+
raise SlackError(f"Slack API error on {method}: {data.get('error')}")
|
|
97
|
+
|
|
98
|
+
def paginate(
|
|
99
|
+
self,
|
|
100
|
+
method: str,
|
|
101
|
+
*,
|
|
102
|
+
collection_key: str,
|
|
103
|
+
params: dict[str, Any] | None = None,
|
|
104
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
105
|
+
) -> list[JSONDict]:
|
|
106
|
+
out: list[JSONDict] = []
|
|
107
|
+
cursor = ""
|
|
108
|
+
while True:
|
|
109
|
+
page_params = {**(params or {}), "limit": limit}
|
|
110
|
+
if cursor:
|
|
111
|
+
page_params["cursor"] = cursor
|
|
112
|
+
data = self.get(method, page_params)
|
|
113
|
+
out.extend(json_dict(item) for item in json_list(data.get(collection_key)))
|
|
114
|
+
cursor = json_str(json_dict(data.get("response_metadata")), "next_cursor")
|
|
115
|
+
if not cursor:
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
def list_users(self) -> list[JSONDict]:
|
|
119
|
+
return self.paginate("users.list", collection_key="members")
|
|
120
|
+
|
|
121
|
+
def validate(self) -> JSONDict:
|
|
122
|
+
"""Validate the token with Slack auth.test and return the response."""
|
|
123
|
+
data = self.get("auth.test")
|
|
124
|
+
if not json_str(data, "user_id"):
|
|
125
|
+
raise SlackError("Slack auth.test response did not include user_id.")
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
def workspace_url(self) -> str:
|
|
129
|
+
url = json_str(self.get("auth.test"), "url").strip().rstrip("/")
|
|
130
|
+
if not url:
|
|
131
|
+
raise SlackError("Slack auth.test response did not include workspace URL.")
|
|
132
|
+
return url
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def slack_client_from_config(
|
|
136
|
+
config: SlackClientConfig,
|
|
137
|
+
*,
|
|
138
|
+
http: HTTPClient | None = None,
|
|
139
|
+
pacer: SlackPacer | None = None,
|
|
140
|
+
) -> SlackClient:
|
|
141
|
+
"""Return a Slack API client from shared Slack Config fields."""
|
|
142
|
+
return SlackClient(
|
|
143
|
+
config.slack_bot_token,
|
|
144
|
+
http=http or HTTPClient(max_attempts=1),
|
|
145
|
+
pacer=pacer or SlackPacer(),
|
|
146
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Sourcegraph GraphQL API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from src_py_lib.clients.graphql import GraphQLClient, stream_connection_nodes
|
|
10
|
+
from src_py_lib.utils.config import Config, config_field
|
|
11
|
+
from src_py_lib.utils.http import HTTPClient
|
|
12
|
+
from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict
|
|
13
|
+
|
|
14
|
+
DEFAULT_SOURCEGRAPH_ENDPOINT = "https://sourcegraph.com"
|
|
15
|
+
SOURCEGRAPH_VALIDATE_QUERY = """
|
|
16
|
+
query SourcegraphClientValidate {
|
|
17
|
+
currentUser {
|
|
18
|
+
username
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_sourcegraph_endpoint(endpoint: str, *, require_https: bool = False) -> str:
|
|
25
|
+
"""Return a stable Sourcegraph base URL, or raise ValueError."""
|
|
26
|
+
normalized_endpoint = endpoint.strip().rstrip("/")
|
|
27
|
+
endpoint_parts = urlsplit(normalized_endpoint)
|
|
28
|
+
if require_https and endpoint_parts.scheme != "https":
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Sourcegraph endpoint must be an https:// URL (got {endpoint_parts.scheme!r})"
|
|
31
|
+
)
|
|
32
|
+
if endpoint_parts.scheme not in {"http", "https"}:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Sourcegraph endpoint must be an http:// or https:// URL "
|
|
35
|
+
f"(got {endpoint_parts.scheme!r})"
|
|
36
|
+
)
|
|
37
|
+
if not endpoint_parts.hostname:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"could not parse hostname from Sourcegraph endpoint {normalized_endpoint!r}"
|
|
40
|
+
)
|
|
41
|
+
return normalized_endpoint
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SourcegraphClientConfig(Config):
|
|
45
|
+
"""Config fields needed to build a Sourcegraph API client."""
|
|
46
|
+
|
|
47
|
+
src_endpoint: str = config_field(
|
|
48
|
+
default=DEFAULT_SOURCEGRAPH_ENDPOINT,
|
|
49
|
+
env_var="SRC_ENDPOINT",
|
|
50
|
+
cli_flag="--src-endpoint",
|
|
51
|
+
metavar="URL",
|
|
52
|
+
help=f"Sourcegraph instance URL (default: {DEFAULT_SOURCEGRAPH_ENDPOINT})",
|
|
53
|
+
)
|
|
54
|
+
src_access_token: str = config_field(
|
|
55
|
+
default="",
|
|
56
|
+
env_var="SRC_ACCESS_TOKEN",
|
|
57
|
+
cli_flag="--src-access-token",
|
|
58
|
+
metavar="TOKEN",
|
|
59
|
+
help="Sourcegraph access token, or op:// secret reference",
|
|
60
|
+
secret=True,
|
|
61
|
+
required=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SourcegraphClient:
|
|
67
|
+
"""Small Sourcegraph GraphQL client.
|
|
68
|
+
|
|
69
|
+
`endpoint` should be the instance base URL, for example
|
|
70
|
+
`https://sourcegraph.example.com`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
endpoint: str
|
|
74
|
+
token: str
|
|
75
|
+
http: HTTPClient = field(default_factory=HTTPClient)
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
self.endpoint = normalize_sourcegraph_endpoint(self.endpoint)
|
|
79
|
+
|
|
80
|
+
def graphql(self, query: str, variables: Mapping[str, JSONValue] | None = None) -> JSONDict:
|
|
81
|
+
return self._client().execute(query, variables)
|
|
82
|
+
|
|
83
|
+
def stream_connection_nodes(
|
|
84
|
+
self,
|
|
85
|
+
query: str,
|
|
86
|
+
variables: Mapping[str, JSONValue] | None = None,
|
|
87
|
+
*,
|
|
88
|
+
connection_path: Sequence[str],
|
|
89
|
+
page_size: int | None = None,
|
|
90
|
+
first_variable: str = "first",
|
|
91
|
+
after_variable: str = "after",
|
|
92
|
+
) -> Iterator[JSONDict]:
|
|
93
|
+
"""Stream one Sourcegraph GraphQL connection's nodes."""
|
|
94
|
+
return stream_connection_nodes(
|
|
95
|
+
self.graphql,
|
|
96
|
+
query,
|
|
97
|
+
variables,
|
|
98
|
+
connection_path=connection_path,
|
|
99
|
+
page_size=page_size,
|
|
100
|
+
first_variable=first_variable,
|
|
101
|
+
after_variable=after_variable,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def validate(self) -> JSONDict:
|
|
105
|
+
"""Validate the token with a cheap current user query and return the user."""
|
|
106
|
+
current_user = json_dict(self.graphql(SOURCEGRAPH_VALIDATE_QUERY).get("currentUser"))
|
|
107
|
+
if not current_user.get("username"):
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"Sourcegraph current user response did not include currentUser.username."
|
|
110
|
+
)
|
|
111
|
+
return current_user
|
|
112
|
+
|
|
113
|
+
def _client(self) -> GraphQLClient:
|
|
114
|
+
return GraphQLClient(
|
|
115
|
+
url=f"{self.endpoint}/.api/graphql",
|
|
116
|
+
headers={"Authorization": f"token {self.token}"},
|
|
117
|
+
label="Sourcegraph",
|
|
118
|
+
http=self.http,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sourcegraph_client_from_config(config: SourcegraphClientConfig) -> SourcegraphClient:
|
|
123
|
+
"""Return a Sourcegraph API client from shared Sourcegraph Config fields."""
|
|
124
|
+
return SourcegraphClient(
|
|
125
|
+
endpoint=config.src_endpoint,
|
|
126
|
+
token=config.src_access_token,
|
|
127
|
+
)
|
src_py_lib/py.typed
ADDED
|
File without changes
|