src-py-lib 0.1.4__tar.gz → 0.1.6__tar.gz
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-0.1.4 → src_py_lib-0.1.6}/.github/workflows/release.yml +8 -3
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/AGENTS.md +4 -4
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/PKG-INFO +1 -1
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/pyproject.toml +1 -1
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/renovate.json +3 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/github.py +5 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/graphql.py +2 -2
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/sourcegraph.py +31 -3
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/http.py +46 -6
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/logging.py +12 -4
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/tests/test_logging_http_clients.py +70 -6
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/uv.lock +1 -1
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/.github/workflows/ci.yml +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/.github/workflows/validate.yml +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/.gitignore +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/.markdownlint-cli2.yaml +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/.python-version +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/LICENSE +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/README.md +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/SECURITY.md +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/__init__.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/__init__.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/google_sheets.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/linear.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/one_password.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/clients/slack.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/py.typed +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/__init__.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/config.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/json_cache.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/json_types.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/src/src_py_lib/utils/tsv.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/tests/test_import.py +0 -0
- {src_py_lib-0.1.4 → src_py_lib-0.1.6}/tests/test_tsv.py +0 -0
|
@@ -12,7 +12,7 @@ on:
|
|
|
12
12
|
type: string
|
|
13
13
|
|
|
14
14
|
permissions:
|
|
15
|
-
contents:
|
|
15
|
+
contents: read
|
|
16
16
|
pull-requests: read
|
|
17
17
|
|
|
18
18
|
concurrency:
|
|
@@ -65,8 +65,10 @@ jobs:
|
|
|
65
65
|
|
|
66
66
|
- name: Validate release inputs
|
|
67
67
|
id: release
|
|
68
|
+
env:
|
|
69
|
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
|
68
70
|
run: |
|
|
69
|
-
release_tag="${
|
|
71
|
+
release_tag="${RELEASE_TAG}"
|
|
70
72
|
if [[ ! "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
71
73
|
echo "::error title=Invalid release tag::Use a vMAJOR.MINOR.PATCH tag, got '${release_tag}'."
|
|
72
74
|
exit 1
|
|
@@ -214,6 +216,8 @@ jobs:
|
|
|
214
216
|
name: Publish GitHub release assets
|
|
215
217
|
needs: [validate, wheel]
|
|
216
218
|
runs-on: ubuntu-24.04
|
|
219
|
+
permissions:
|
|
220
|
+
contents: write
|
|
217
221
|
|
|
218
222
|
steps:
|
|
219
223
|
- name: Download release assets
|
|
@@ -226,8 +230,9 @@ jobs:
|
|
|
226
230
|
env:
|
|
227
231
|
GH_TOKEN: ${{ github.token }}
|
|
228
232
|
GH_REPO: ${{ github.repository }}
|
|
233
|
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
|
229
234
|
run: |
|
|
230
|
-
release_tag="${
|
|
235
|
+
release_tag="${RELEASE_TAG}"
|
|
231
236
|
notes_path="$(find release-assets -name release-notes.md -print -quit)"
|
|
232
237
|
mapfile -t release_assets < <(find release-assets -type f ! -name release-notes.md | sort)
|
|
233
238
|
|
|
@@ -64,7 +64,7 @@ uv run python -m unittest discover -s tests
|
|
|
64
64
|
```sh
|
|
65
65
|
set -euo pipefail
|
|
66
66
|
|
|
67
|
-
VERSION=0.1.
|
|
67
|
+
VERSION=0.1.6
|
|
68
68
|
BRANCH="release-v${VERSION}"
|
|
69
69
|
|
|
70
70
|
git fetch origin --tags --prune
|
|
@@ -116,7 +116,7 @@ rm -rf /tmp/src-py-lib-release-check
|
|
|
116
116
|
```sh
|
|
117
117
|
set -euo pipefail
|
|
118
118
|
|
|
119
|
-
VERSION=0.1.
|
|
119
|
+
VERSION=0.1.6
|
|
120
120
|
BRANCH="release-v${VERSION}"
|
|
121
121
|
GH_REPO="sourcegraph/src-py-lib"
|
|
122
122
|
|
|
@@ -140,7 +140,7 @@ gh pr merge "${BRANCH}" --repo "${GH_REPO}" --squash --delete-branch
|
|
|
140
140
|
```sh
|
|
141
141
|
set -euo pipefail
|
|
142
142
|
|
|
143
|
-
VERSION=0.1.
|
|
143
|
+
VERSION=0.1.6
|
|
144
144
|
|
|
145
145
|
git fetch origin --tags --prune
|
|
146
146
|
git switch main
|
|
@@ -154,7 +154,7 @@ git push origin "v${VERSION}"
|
|
|
154
154
|
```sh
|
|
155
155
|
set -euo pipefail
|
|
156
156
|
|
|
157
|
-
VERSION=0.1.
|
|
157
|
+
VERSION=0.1.6
|
|
158
158
|
GH_REPO="sourcegraph/src-py-lib"
|
|
159
159
|
|
|
160
160
|
RUN_ID="$(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: src-py-lib
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Reusable libraries for Sourcegraph projects
|
|
5
5
|
Project-URL: Homepage, https://github.com/sourcegraph/src-py-lib
|
|
6
6
|
Project-URL: Issues, https://github.com/sourcegraph/src-py-lib/issues
|
|
@@ -113,6 +113,11 @@ def _normalize_github_url(github_url: str) -> str:
|
|
|
113
113
|
stripped = github_url.strip().rstrip("/")
|
|
114
114
|
if "://" not in stripped:
|
|
115
115
|
stripped = f"https://{stripped}"
|
|
116
|
+
split = urlsplit(stripped)
|
|
117
|
+
if split.scheme != "https":
|
|
118
|
+
raise ValueError(f"GitHub URL must be an https:// URL (got {split.scheme!r})")
|
|
119
|
+
if not split.hostname:
|
|
120
|
+
raise ValueError(f"could not parse hostname from GitHub URL {stripped!r}")
|
|
116
121
|
return stripped
|
|
117
122
|
|
|
118
123
|
|
|
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import cast
|
|
11
11
|
|
|
12
|
-
from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse
|
|
12
|
+
from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse, log_safe_url
|
|
13
13
|
from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list, json_str
|
|
14
14
|
from src_py_lib.utils.logging import event
|
|
15
15
|
|
|
@@ -249,7 +249,7 @@ class GraphQLClient:
|
|
|
249
249
|
page_number=page_number,
|
|
250
250
|
page_size=_int_variable(variables, first_variable),
|
|
251
251
|
cursor_present=variables.get(after_variable) is not None,
|
|
252
|
-
url=self.url,
|
|
252
|
+
url=log_safe_url(self.url),
|
|
253
253
|
variable_names=sorted(variables),
|
|
254
254
|
query_bytes=len(query.encode("utf-8")),
|
|
255
255
|
) as fields:
|
|
@@ -182,6 +182,9 @@ class SourcegraphClient:
|
|
|
182
182
|
`endpoint` should be the instance base URL, for example
|
|
183
183
|
`https://sourcegraph.example.com`.
|
|
184
184
|
|
|
185
|
+
Plain HTTP endpoints are rejected unless `allow_insecure_http=True` is set
|
|
186
|
+
for local development.
|
|
187
|
+
|
|
185
188
|
Set `trace=True` to ask Sourcegraph to retain traces for each GraphQL
|
|
186
189
|
request. Traced requests are available through `drain_traces()` and can be
|
|
187
190
|
fetched from the instance's Jaeger/debug endpoint with
|
|
@@ -192,15 +195,40 @@ class SourcegraphClient:
|
|
|
192
195
|
token: str
|
|
193
196
|
http: HTTPClient = field(default_factory=HTTPClient)
|
|
194
197
|
trace: bool = False
|
|
198
|
+
allow_insecure_http: bool = False
|
|
195
199
|
_traces: queue.Queue[SourcegraphTrace] = field(
|
|
196
200
|
default_factory=lambda: queue.Queue[SourcegraphTrace](), init=False, repr=False
|
|
197
201
|
)
|
|
198
202
|
|
|
199
203
|
def __post_init__(self) -> None:
|
|
200
|
-
self.endpoint = normalize_sourcegraph_endpoint(
|
|
204
|
+
self.endpoint = normalize_sourcegraph_endpoint(
|
|
205
|
+
self.endpoint,
|
|
206
|
+
require_https=not self.allow_insecure_http,
|
|
207
|
+
)
|
|
201
208
|
|
|
202
|
-
def graphql(
|
|
203
|
-
|
|
209
|
+
def graphql(
|
|
210
|
+
self,
|
|
211
|
+
query: str,
|
|
212
|
+
variables: Mapping[str, JSONValue] | None = None,
|
|
213
|
+
*,
|
|
214
|
+
follow_pages: bool = True,
|
|
215
|
+
page_size: int | None = None,
|
|
216
|
+
first_variable: str = "first",
|
|
217
|
+
after_variable: str = "after",
|
|
218
|
+
) -> JSONDict:
|
|
219
|
+
"""Execute one Sourcegraph GraphQL operation.
|
|
220
|
+
|
|
221
|
+
Set `follow_pages=False` when the caller owns pagination, such as
|
|
222
|
+
aliased queries with one cursor per alias.
|
|
223
|
+
"""
|
|
224
|
+
return self._client().execute(
|
|
225
|
+
query,
|
|
226
|
+
variables,
|
|
227
|
+
follow_pages=follow_pages,
|
|
228
|
+
page_size=page_size,
|
|
229
|
+
first_variable=first_variable,
|
|
230
|
+
after_variable=after_variable,
|
|
231
|
+
)
|
|
204
232
|
|
|
205
233
|
def stream_connection_nodes(
|
|
206
234
|
self,
|
|
@@ -33,6 +33,19 @@ SENSITIVE_HEADER_FRAGMENTS: Final[tuple[str, ...]] = (
|
|
|
33
33
|
"secret",
|
|
34
34
|
"token",
|
|
35
35
|
)
|
|
36
|
+
SENSITIVE_URL_QUERY_FRAGMENTS: Final[tuple[str, ...]] = (
|
|
37
|
+
"access_token",
|
|
38
|
+
"api-key",
|
|
39
|
+
"api_key",
|
|
40
|
+
"authorization",
|
|
41
|
+
"code",
|
|
42
|
+
"credential",
|
|
43
|
+
"key",
|
|
44
|
+
"password",
|
|
45
|
+
"secret",
|
|
46
|
+
"signature",
|
|
47
|
+
"token",
|
|
48
|
+
)
|
|
36
49
|
|
|
37
50
|
logger = logging.getLogger(__name__)
|
|
38
51
|
|
|
@@ -150,7 +163,7 @@ class HTTPClient:
|
|
|
150
163
|
"http_request",
|
|
151
164
|
level="debug",
|
|
152
165
|
method=method,
|
|
153
|
-
url=
|
|
166
|
+
url=log_safe_url(request_url),
|
|
154
167
|
attempt=attempt,
|
|
155
168
|
request_headers=_headers_for_log(request_headers),
|
|
156
169
|
request_bytes=len(body or b""),
|
|
@@ -179,7 +192,7 @@ class HTTPClient:
|
|
|
179
192
|
if not self._should_retry(response.status_code, attempt):
|
|
180
193
|
raise HTTPClientError(
|
|
181
194
|
f"HTTP {response.status_code} for {method} "
|
|
182
|
-
f"{
|
|
195
|
+
f"{log_safe_url(request_url)}: {body_text}",
|
|
183
196
|
status_code=response.status_code,
|
|
184
197
|
body=body_text,
|
|
185
198
|
headers=dict(response.headers),
|
|
@@ -203,7 +216,7 @@ class HTTPClient:
|
|
|
203
216
|
"timed out" if isinstance(exception, httpx.TimeoutException) else "failed"
|
|
204
217
|
)
|
|
205
218
|
raise HTTPClientError(
|
|
206
|
-
f"HTTP request {failure} for {method} {
|
|
219
|
+
f"HTTP request {failure} for {method} {log_safe_url(request_url)}: "
|
|
207
220
|
f"{_exception_message(exception)}"
|
|
208
221
|
) from exception
|
|
209
222
|
record_http_retry()
|
|
@@ -246,7 +259,7 @@ class HTTPClient:
|
|
|
246
259
|
), response
|
|
247
260
|
except json.JSONDecodeError as exception:
|
|
248
261
|
raise HTTPClientError(
|
|
249
|
-
f"Invalid JSON response from {method} {
|
|
262
|
+
f"Invalid JSON response from {method} {log_safe_url(url)}"
|
|
250
263
|
) from exception
|
|
251
264
|
|
|
252
265
|
def _should_retry(self, status_code: int | None, attempt: int) -> bool:
|
|
@@ -276,9 +289,31 @@ def _with_query(
|
|
|
276
289
|
return f"{url}{separator}{urllib.parse.urlencode(filtered)}"
|
|
277
290
|
|
|
278
291
|
|
|
279
|
-
def
|
|
292
|
+
def log_safe_url(url: str) -> str:
|
|
293
|
+
"""Return a URL safe to include in logs and exception messages."""
|
|
280
294
|
split = urllib.parse.urlsplit(url)
|
|
281
|
-
return urllib.parse.urlunsplit(
|
|
295
|
+
return urllib.parse.urlunsplit(
|
|
296
|
+
(split.scheme, _safe_netloc(split.netloc), split.path, _safe_query(split.query), "")
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _safe_netloc(netloc: str) -> str:
|
|
301
|
+
return netloc.rsplit("@", 1)[-1]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _safe_query(query: str) -> str:
|
|
305
|
+
if not query:
|
|
306
|
+
return ""
|
|
307
|
+
parts: list[str] = []
|
|
308
|
+
for part in query.split("&"):
|
|
309
|
+
key, separator, _value = part.partition("=")
|
|
310
|
+
if _is_sensitive_query_parameter(urllib.parse.unquote_plus(key)):
|
|
311
|
+
parts.append(f"{key}={REDACTED_HEADER_VALUE}")
|
|
312
|
+
elif separator:
|
|
313
|
+
parts.append(part)
|
|
314
|
+
else:
|
|
315
|
+
parts.append(key)
|
|
316
|
+
return "&".join(parts)
|
|
282
317
|
|
|
283
318
|
|
|
284
319
|
def _headers_for_log(headers: Mapping[str, str] | httpx.Headers) -> dict[str, str | list[str]]:
|
|
@@ -311,6 +346,11 @@ def _is_sensitive_header(name: str) -> bool:
|
|
|
311
346
|
return any(fragment in lowered for fragment in SENSITIVE_HEADER_FRAGMENTS)
|
|
312
347
|
|
|
313
348
|
|
|
349
|
+
def _is_sensitive_query_parameter(name: str) -> bool:
|
|
350
|
+
lowered = name.lower()
|
|
351
|
+
return any(fragment in lowered for fragment in SENSITIVE_URL_QUERY_FRAGMENTS)
|
|
352
|
+
|
|
353
|
+
|
|
314
354
|
def _response_http_version(response: httpx.Response) -> str | None:
|
|
315
355
|
version = response.extensions.get("http_version")
|
|
316
356
|
if isinstance(version, bytes):
|
|
@@ -43,7 +43,9 @@ SRC_LOG_SILENT: Final[str] = "SRC_LOG_SILENT"
|
|
|
43
43
|
TRACE_ID_BYTES: Final[int] = 16
|
|
44
44
|
SPAN_ID_BYTES: Final[int] = 8
|
|
45
45
|
MEBIBYTE: Final[int] = 1024 * 1024
|
|
46
|
+
REDACTED_LOG_VALUE: Final[str] = "[redacted]"
|
|
46
47
|
SECRET_FIELD_FRAGMENTS: Final[tuple[str, ...]] = (
|
|
48
|
+
"api-key",
|
|
47
49
|
"api_key",
|
|
48
50
|
"authorization",
|
|
49
51
|
"cookie",
|
|
@@ -768,7 +770,7 @@ def sanitized_config_snapshot(config: object) -> dict[str, Any]:
|
|
|
768
770
|
if callable(value):
|
|
769
771
|
continue
|
|
770
772
|
key_text = str(key)
|
|
771
|
-
if
|
|
773
|
+
if _is_sensitive_log_field(key_text):
|
|
772
774
|
snapshot[key_text] = _secret_state(value)
|
|
773
775
|
elif isinstance(value, Path):
|
|
774
776
|
snapshot[key_text] = str(value)
|
|
@@ -937,13 +939,14 @@ def _http_headers(raw_headers: object) -> dict[str, str | list[str]]:
|
|
|
937
939
|
if name is None or value is None:
|
|
938
940
|
continue
|
|
939
941
|
key = name.lower()
|
|
942
|
+
logged_value = REDACTED_LOG_VALUE if _is_sensitive_log_field(key) else value
|
|
940
943
|
existing = headers.get(key)
|
|
941
944
|
if existing is None:
|
|
942
|
-
headers[key] =
|
|
945
|
+
headers[key] = logged_value
|
|
943
946
|
elif isinstance(existing, list):
|
|
944
|
-
existing.append(
|
|
947
|
+
existing.append(logged_value)
|
|
945
948
|
else:
|
|
946
|
-
headers[key] = [existing,
|
|
949
|
+
headers[key] = [existing, logged_value]
|
|
947
950
|
return {key: headers[key] for key in sorted(headers)}
|
|
948
951
|
|
|
949
952
|
|
|
@@ -971,6 +974,11 @@ def _is_hex_identifier(value: str, length: int) -> bool:
|
|
|
971
974
|
)
|
|
972
975
|
|
|
973
976
|
|
|
977
|
+
def _is_sensitive_log_field(name: str) -> bool:
|
|
978
|
+
lowered = name.lower()
|
|
979
|
+
return any(fragment in lowered for fragment in SECRET_FIELD_FRAGMENTS)
|
|
980
|
+
|
|
981
|
+
|
|
974
982
|
def _secret_state(value: object) -> str:
|
|
975
983
|
if value is None or value == "":
|
|
976
984
|
return "missing"
|
|
@@ -1209,7 +1209,8 @@ class LoggingTest(unittest.TestCase):
|
|
|
1209
1209
|
"receive_response_headers.complete "
|
|
1210
1210
|
"return_value=(b'HTTP/1.1', 200, b'OK', "
|
|
1211
1211
|
"[(b'Zed', b'last'), (b'Content-Type', b'application/json'), "
|
|
1212
|
-
"(b'
|
|
1212
|
+
"(b'Set-Cookie', b'session=secret'), "
|
|
1213
|
+
"(b'X-Api-Key', b'secret'), (b'Alpha', b'first')])"
|
|
1213
1214
|
)
|
|
1214
1215
|
finally:
|
|
1215
1216
|
logger = logging.getLogger(logger_name)
|
|
@@ -1226,12 +1227,17 @@ class LoggingTest(unittest.TestCase):
|
|
|
1226
1227
|
self.assertEqual(response_headers["http_version"], "HTTP/1.1")
|
|
1227
1228
|
self.assertEqual(response_headers["status_code"], 200)
|
|
1228
1229
|
self.assertEqual(response_headers["reason_phrase"], "OK")
|
|
1229
|
-
self.assertEqual(
|
|
1230
|
+
self.assertEqual(
|
|
1231
|
+
list(response_headers["headers"]),
|
|
1232
|
+
["alpha", "content-type", "set-cookie", "x-api-key", "zed"],
|
|
1233
|
+
)
|
|
1230
1234
|
self.assertEqual(
|
|
1231
1235
|
response_headers["headers"],
|
|
1232
1236
|
{
|
|
1233
1237
|
"alpha": "first",
|
|
1234
1238
|
"content-type": "application/json",
|
|
1239
|
+
"set-cookie": "[redacted]",
|
|
1240
|
+
"x-api-key": "[redacted]",
|
|
1235
1241
|
"zed": "last",
|
|
1236
1242
|
},
|
|
1237
1243
|
)
|
|
@@ -1311,8 +1317,9 @@ class HTTPClientTest(unittest.TestCase):
|
|
|
1311
1317
|
client = HTTPClient(max_attempts=1, transport=httpx.MockTransport(handler))
|
|
1312
1318
|
payload = client.json(
|
|
1313
1319
|
"POST",
|
|
1314
|
-
"https://example.com/api",
|
|
1320
|
+
"https://user:pass@example.com/api?code=oauth",
|
|
1315
1321
|
headers={"Authorization": "Bearer token"},
|
|
1322
|
+
query={"limit": 10, "access_token": "secret", "signature": "signed"},
|
|
1316
1323
|
json_body={"hello": "world"},
|
|
1317
1324
|
)
|
|
1318
1325
|
finally:
|
|
@@ -1332,6 +1339,11 @@ class HTTPClientTest(unittest.TestCase):
|
|
|
1332
1339
|
self.assertFalse(any(row.get("logger") in {"httpx", "httpcore"} for row in rows))
|
|
1333
1340
|
self.assertEqual(http_request["status_code"], 200)
|
|
1334
1341
|
self.assertEqual(http_request["reason_phrase"], "OK")
|
|
1342
|
+
self.assertEqual(
|
|
1343
|
+
http_request["url"],
|
|
1344
|
+
"https://example.com/api?code=[redacted]&limit=10"
|
|
1345
|
+
"&access_token=[redacted]&signature=[redacted]",
|
|
1346
|
+
)
|
|
1335
1347
|
self.assertEqual(http_request["request_bytes"], len(b'{"hello": "world"}'))
|
|
1336
1348
|
self.assertEqual(http_request["request_headers"]["authorization"], "[redacted]")
|
|
1337
1349
|
self.assertEqual(
|
|
@@ -1364,10 +1376,13 @@ class HTTPClientTest(unittest.TestCase):
|
|
|
1364
1376
|
)
|
|
1365
1377
|
|
|
1366
1378
|
with self.assertRaisesRegex(HTTPClientError, "rate limited") as raised:
|
|
1367
|
-
client.json("GET", "https://example.com/api")
|
|
1379
|
+
client.json("GET", "https://user:pass@example.com/api?access_token=secret")
|
|
1368
1380
|
|
|
1369
1381
|
self.assertEqual(raised.exception.status_code, 429)
|
|
1370
1382
|
self.assertEqual(raised.exception.body, "rate limited")
|
|
1383
|
+
self.assertIn("https://example.com/api?access_token=[redacted]", str(raised.exception))
|
|
1384
|
+
self.assertNotIn("user:pass", str(raised.exception))
|
|
1385
|
+
self.assertNotIn("secret", str(raised.exception))
|
|
1371
1386
|
|
|
1372
1387
|
|
|
1373
1388
|
class ClientTest(unittest.TestCase):
|
|
@@ -1409,6 +1424,42 @@ class ClientTest(unittest.TestCase):
|
|
|
1409
1424
|
self.assertEqual(http.calls[0]["url"], "https://sourcegraph.example.com/.api/graphql")
|
|
1410
1425
|
self.assertEqual(http.calls[0]["headers"], {"Authorization": "token token"})
|
|
1411
1426
|
|
|
1427
|
+
def test_sourcegraph_client_graphql_can_disable_auto_pagination(self) -> None:
|
|
1428
|
+
http = RecordingHTTP(
|
|
1429
|
+
[
|
|
1430
|
+
{
|
|
1431
|
+
"data": {
|
|
1432
|
+
"users": {
|
|
1433
|
+
"nodes": [{"username": "alice"}],
|
|
1434
|
+
"pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"},
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
]
|
|
1439
|
+
)
|
|
1440
|
+
client = SourcegraphClient("https://sourcegraph.example.com", "token", http=http)
|
|
1441
|
+
query = """
|
|
1442
|
+
query Users($first: Int!, $after: String) {
|
|
1443
|
+
users(first: $first, after: $after) {
|
|
1444
|
+
nodes { username }
|
|
1445
|
+
pageInfo { hasNextPage endCursor }
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
"""
|
|
1449
|
+
|
|
1450
|
+
data = client.graphql(query, page_size=1, follow_pages=False)
|
|
1451
|
+
|
|
1452
|
+
self.assertEqual(json_dict(data.get("users"))["nodes"], [{"username": "alice"}])
|
|
1453
|
+
self.assertEqual(len(http.calls), 1)
|
|
1454
|
+
self.assertEqual(http.calls[0]["json_body"]["variables"], {"first": 1})
|
|
1455
|
+
|
|
1456
|
+
def test_sourcegraph_client_rejects_http_endpoint_by_default(self) -> None:
|
|
1457
|
+
with self.assertRaisesRegex(ValueError, "https:// URL"):
|
|
1458
|
+
SourcegraphClient("http://sourcegraph.example.com", "token")
|
|
1459
|
+
|
|
1460
|
+
client = SourcegraphClient("http://localhost:3080", "token", allow_insecure_http=True)
|
|
1461
|
+
self.assertEqual(client.endpoint, "http://localhost:3080")
|
|
1462
|
+
|
|
1412
1463
|
def test_sourcegraph_client_streams_connection_nodes(self) -> None:
|
|
1413
1464
|
http = RecordingHTTP(
|
|
1414
1465
|
[
|
|
@@ -1514,7 +1565,7 @@ class ClientTest(unittest.TestCase):
|
|
|
1514
1565
|
|
|
1515
1566
|
with src.trace_context(root_context):
|
|
1516
1567
|
self.assertEqual(
|
|
1517
|
-
client.graphql("query Viewer { currentUser { username } }"),
|
|
1568
|
+
client.graphql("query Viewer { currentUser { username } }", follow_pages=False),
|
|
1518
1569
|
{"currentUser": {"username": "alice"}},
|
|
1519
1570
|
)
|
|
1520
1571
|
traces = client.drain_traces()
|
|
@@ -1780,7 +1831,12 @@ query Items($first: Int!, $after: String, $userId: ID!) {
|
|
|
1780
1831
|
},
|
|
1781
1832
|
]
|
|
1782
1833
|
)
|
|
1783
|
-
client = GraphQLClient(
|
|
1834
|
+
client = GraphQLClient(
|
|
1835
|
+
"https://user:pass@example.com/graphql?access_token=secret&query=ok",
|
|
1836
|
+
{},
|
|
1837
|
+
"Example",
|
|
1838
|
+
http=http,
|
|
1839
|
+
)
|
|
1784
1840
|
query = """
|
|
1785
1841
|
query Items($first: Int!, $after: String, $userId: ID!) {
|
|
1786
1842
|
viewer { items { nodes { id } pageInfo { hasNextPage endCursor } } }
|
|
@@ -1822,6 +1878,10 @@ query Items($first: Int!, $after: String, $userId: ID!) {
|
|
|
1822
1878
|
self.assertEqual([row["page_size"] for row in starts], [2, 2])
|
|
1823
1879
|
self.assertEqual([row["cursor_present"] for row in starts], [False, True])
|
|
1824
1880
|
self.assertEqual(starts[0]["graphql_client"], "Example")
|
|
1881
|
+
self.assertEqual(
|
|
1882
|
+
starts[0]["url"],
|
|
1883
|
+
"https://example.com/graphql?access_token=[redacted]&query=ok",
|
|
1884
|
+
)
|
|
1825
1885
|
self.assertEqual(starts[0]["variable_names"], ["after", "first", "userId"])
|
|
1826
1886
|
self.assertEqual(ends[0]["response_fields"], ["viewer"])
|
|
1827
1887
|
|
|
@@ -1946,6 +2006,10 @@ query Items($first: Int!, $after: String) {
|
|
|
1946
2006
|
graphql_api_url("github.example.com"), "https://github.example.com/api/graphql"
|
|
1947
2007
|
)
|
|
1948
2008
|
|
|
2009
|
+
def test_github_client_rejects_http_enterprise_url(self) -> None:
|
|
2010
|
+
with self.assertRaisesRegex(ValueError, "https:// URL"):
|
|
2011
|
+
graphql_api_url("http://github.example.com")
|
|
2012
|
+
|
|
1949
2013
|
def test_github_client_validate_queries_viewer(self) -> None:
|
|
1950
2014
|
http = RecordingHTTP([{"data": {"viewer": {"login": "alice"}}}])
|
|
1951
2015
|
client = GitHubClient("token", http=http)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|