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,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
@@ -0,0 +1,3 @@
1
+ """Foundational utilities used by src_py_lib clients and consumers."""
2
+
3
+ from __future__ import annotations