src-py-lib 0.1.3__tar.gz → 0.1.5__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.
Files changed (35) hide show
  1. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.github/workflows/ci.yml +1 -0
  2. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.github/workflows/release.yml +10 -4
  3. src_py_lib-0.1.5/.github/workflows/validate.yml +280 -0
  4. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/AGENTS.md +4 -4
  5. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/PKG-INFO +1 -1
  6. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/pyproject.toml +1 -1
  7. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/__init__.py +1 -0
  8. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/github.py +5 -0
  9. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/graphql.py +2 -2
  10. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/sourcegraph.py +29 -6
  11. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/http.py +46 -6
  12. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/logging.py +12 -4
  13. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_logging_http_clients.py +86 -5
  14. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/uv.lock +1 -1
  15. src_py_lib-0.1.3/.github/workflows/validate.yml +0 -135
  16. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.gitignore +0 -0
  17. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.markdownlint-cli2.yaml +0 -0
  18. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.python-version +0 -0
  19. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/LICENSE +0 -0
  20. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/README.md +0 -0
  21. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/SECURITY.md +0 -0
  22. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/renovate.json +0 -0
  23. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/__init__.py +0 -0
  24. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/google_sheets.py +0 -0
  25. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/linear.py +0 -0
  26. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/one_password.py +0 -0
  27. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/slack.py +0 -0
  28. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/py.typed +0 -0
  29. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/__init__.py +0 -0
  30. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/config.py +0 -0
  31. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/json_cache.py +0 -0
  32. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/json_types.py +0 -0
  33. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/tsv.py +0 -0
  34. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_import.py +0 -0
  35. {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_tsv.py +0 -0
@@ -5,6 +5,7 @@ on:
5
5
 
6
6
  permissions:
7
7
  contents: read
8
+ pull-requests: read
8
9
 
9
10
  concurrency:
10
11
  group: ci-${{ github.workflow }}-${{ github.ref }}
@@ -12,7 +12,8 @@ on:
12
12
  type: string
13
13
 
14
14
  permissions:
15
- contents: write
15
+ contents: read
16
+ pull-requests: read
16
17
 
17
18
  concurrency:
18
19
  group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -50,7 +51,6 @@ jobs:
50
51
  uses: actions/setup-python@v6
51
52
  with:
52
53
  python-version: ${{ env.PYTHON_VERSION }}
53
- cache: pip
54
54
 
55
55
  - name: Cache uv
56
56
  uses: actions/cache@v5
@@ -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="${{ github.event.inputs.tag || github.ref_name }}"
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
@@ -225,8 +229,10 @@ jobs:
225
229
  - name: Publish GitHub release assets
226
230
  env:
227
231
  GH_TOKEN: ${{ github.token }}
232
+ GH_REPO: ${{ github.repository }}
233
+ RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
228
234
  run: |
229
- release_tag="${{ github.event.inputs.tag || github.ref_name }}"
235
+ release_tag="${RELEASE_TAG}"
230
236
  notes_path="$(find release-assets -name release-notes.md -print -quit)"
231
237
  mapfile -t release_assets < <(find release-assets -type f ! -name release-notes.md | sort)
232
238
 
@@ -0,0 +1,280 @@
1
+ name: Validate
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ ref:
7
+ description: "Git ref to validate. Defaults to the caller's ref."
8
+ required: false
9
+ type: string
10
+ build-package:
11
+ description: "Build and smoke-test package artifacts. Release builds do this separately."
12
+ required: false
13
+ type: boolean
14
+ default: true
15
+
16
+ permissions:
17
+ contents: read
18
+ pull-requests: read
19
+
20
+ defaults:
21
+ run:
22
+ shell: bash
23
+
24
+ jobs:
25
+ changes:
26
+ name: Detect changed paths
27
+ runs-on: ubuntu-24.04
28
+ outputs:
29
+ github_actions: ${{ steps.changed_paths.outputs.github_actions }}
30
+ markdown: ${{ steps.changed_paths.outputs.markdown }}
31
+ python: ${{ steps.changed_paths.outputs.python }}
32
+ package: ${{ steps.changed_paths.outputs.package }}
33
+
34
+ steps:
35
+ - name: Detect changed paths
36
+ id: changed_paths
37
+ env:
38
+ GH_TOKEN: ${{ github.token }}
39
+ PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
40
+ run: |
41
+ github_actions_changed=false
42
+ markdown_changed=false
43
+ python_changed=false
44
+ package_changed=false
45
+
46
+ if [[ "${{ github.event_name }}" != "pull_request" ]]; then
47
+ github_actions_changed=true
48
+ markdown_changed=true
49
+ python_changed=true
50
+ package_changed=true
51
+ else
52
+ changed_files="$(mktemp)"
53
+ gh api --paginate \
54
+ "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}/files" \
55
+ --jq '.[].filename' > "${changed_files}"
56
+
57
+ while IFS= read -r changed_file; do
58
+ case "${changed_file}" in
59
+ .github/workflows/*)
60
+ github_actions_changed=true
61
+ ;;
62
+ esac
63
+
64
+ case "${changed_file}" in
65
+ *.md|.markdownlint-cli2.yaml)
66
+ markdown_changed=true
67
+ ;;
68
+ esac
69
+
70
+ case "${changed_file}" in
71
+ .python-version|pyproject.toml|uv.lock|src/*|tests/*)
72
+ python_changed=true
73
+ ;;
74
+ esac
75
+
76
+ case "${changed_file}" in
77
+ .python-version|LICENSE|README.md|pyproject.toml|uv.lock|src/*)
78
+ package_changed=true
79
+ ;;
80
+ esac
81
+ done < "${changed_files}"
82
+ fi
83
+
84
+ {
85
+ echo "github_actions=${github_actions_changed}"
86
+ echo "markdown=${markdown_changed}"
87
+ echo "python=${python_changed}"
88
+ echo "package=${package_changed}"
89
+ } >> "${GITHUB_OUTPUT}"
90
+
91
+ github_actions:
92
+ name: Lint GitHub Actions
93
+ needs: changes
94
+ if: needs.changes.outputs.github_actions == 'true'
95
+ runs-on: ubuntu-24.04
96
+ env:
97
+ ACTIONLINT_VERSION: "1.7.12"
98
+
99
+ steps:
100
+ - name: Check out code
101
+ uses: actions/checkout@v6
102
+ with:
103
+ persist-credentials: false
104
+ ref: ${{ inputs.ref || github.ref }}
105
+
106
+ - name: Install actionlint
107
+ run: |
108
+ mkdir -p "${HOME}/.local/bin"
109
+ asset="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
110
+ checksums="actionlint_${ACTIONLINT_VERSION}_checksums.txt"
111
+ base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
112
+
113
+ curl -fsSLO "${base_url}/${asset}"
114
+ curl -fsSLO "${base_url}/${checksums}"
115
+ grep " ${asset}$" "${checksums}" | sha256sum --check
116
+ tar -xzf "${asset}" -C "${HOME}/.local/bin" actionlint
117
+ chmod 0755 "${HOME}/.local/bin/actionlint"
118
+
119
+ - name: Lint GitHub Actions
120
+ run: |
121
+ "${HOME}/.local/bin/actionlint"
122
+
123
+ markdown:
124
+ name: Lint Markdown
125
+ needs: changes
126
+ if: needs.changes.outputs.markdown == 'true'
127
+ runs-on: ubuntu-24.04
128
+ env:
129
+ MARKDOWNLINT_CLI2_VERSION: "0.22.1"
130
+
131
+ steps:
132
+ - name: Check out code
133
+ uses: actions/checkout@v6
134
+ with:
135
+ persist-credentials: false
136
+ ref: ${{ inputs.ref || github.ref }}
137
+
138
+ - name: Cache npm
139
+ uses: actions/cache@v5
140
+ with:
141
+ path: ~/.npm
142
+ key: npm-${{ runner.os }}-markdownlint-cli2-${{ env.MARKDOWNLINT_CLI2_VERSION }}
143
+
144
+ - name: Lint Markdown
145
+ run: npx --yes "markdownlint-cli2@${MARKDOWNLINT_CLI2_VERSION}"
146
+
147
+ python:
148
+ name: Validate Python
149
+ needs: changes
150
+ if: needs.changes.outputs.python == 'true'
151
+ runs-on: ubuntu-24.04
152
+ env:
153
+ IMPORT_NAME: src_py_lib
154
+ PYTHON_VERSION: "3.11"
155
+ UV_VERSION: "0.11.7"
156
+
157
+ steps:
158
+ - name: Check out code
159
+ uses: actions/checkout@v6
160
+ with:
161
+ persist-credentials: false
162
+ ref: ${{ inputs.ref || github.ref }}
163
+
164
+ - name: Set up Python
165
+ uses: actions/setup-python@v6
166
+ with:
167
+ python-version: ${{ env.PYTHON_VERSION }}
168
+
169
+ - name: Cache uv
170
+ uses: actions/cache@v5
171
+ with:
172
+ path: ~/.cache/uv
173
+ key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
174
+ restore-keys: |
175
+ uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-
176
+
177
+ - name: Install uv
178
+ run: python -m pip install "uv==${UV_VERSION}"
179
+
180
+ - name: Validate lockfile
181
+ run: uv lock --check
182
+
183
+ - name: Lint Python
184
+ run: uv run --frozen ruff check .
185
+
186
+ - name: Check Python formatting
187
+ run: uv run --frozen ruff format --check .
188
+
189
+ - name: Type check
190
+ run: uv run --frozen pyright
191
+
192
+ - name: Run tests
193
+ run: uv run --frozen python -m unittest discover -s tests
194
+
195
+ - name: Smoke test source checkout import
196
+ run: |
197
+ uv run --frozen python - <<'PY'
198
+ import os
199
+
200
+ import src_py_lib
201
+
202
+ if src_py_lib.__name__ != os.environ["IMPORT_NAME"]:
203
+ raise SystemExit(f"unexpected import name: {src_py_lib.__name__}")
204
+ PY
205
+
206
+ package_build:
207
+ name: Build and smoke-test package
208
+ needs: changes
209
+ if: inputs.build-package && needs.changes.outputs.package == 'true'
210
+ runs-on: ubuntu-24.04
211
+ env:
212
+ IMPORT_NAME: src_py_lib
213
+ PYTHON_VERSION: "3.11"
214
+ UV_VERSION: "0.11.7"
215
+
216
+ steps:
217
+ - name: Check out code
218
+ uses: actions/checkout@v6
219
+ with:
220
+ persist-credentials: false
221
+ ref: ${{ inputs.ref || github.ref }}
222
+
223
+ - name: Set up Python
224
+ uses: actions/setup-python@v6
225
+ with:
226
+ python-version: ${{ env.PYTHON_VERSION }}
227
+
228
+ - name: Cache uv
229
+ uses: actions/cache@v5
230
+ with:
231
+ path: ~/.cache/uv
232
+ key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
233
+ restore-keys: |
234
+ uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-
235
+
236
+ - name: Install uv
237
+ run: python -m pip install "uv==${UV_VERSION}"
238
+
239
+ - name: Build wheel
240
+ run: uv build --wheel --out-dir dist --no-create-gitignore
241
+
242
+ - name: Smoke test installed wheel
243
+ run: |
244
+ python -m venv build/ci-venv
245
+ . build/ci-venv/bin/activate
246
+ python -m pip install dist/*.whl
247
+ python - <<'PY'
248
+ import os
249
+
250
+ import src_py_lib
251
+
252
+ if src_py_lib.__name__ != os.environ["IMPORT_NAME"]:
253
+ raise SystemExit(f"unexpected import name: {src_py_lib.__name__}")
254
+ PY
255
+
256
+ package:
257
+ name: Validate package
258
+ needs: [changes, github_actions, markdown, python, package_build]
259
+ if: always()
260
+ runs-on: ubuntu-24.04
261
+
262
+ steps:
263
+ - name: Confirm validation results
264
+ run: |
265
+ for validation_result in \
266
+ "${{ needs.changes.result }}" \
267
+ "${{ needs.github_actions.result }}" \
268
+ "${{ needs.markdown.result }}" \
269
+ "${{ needs.python.result }}" \
270
+ "${{ needs.package_build.result }}"
271
+ do
272
+ case "${validation_result}" in
273
+ success|skipped)
274
+ ;;
275
+ *)
276
+ echo "::error title=Validation failed::At least one validation job ended with '${validation_result}'."
277
+ exit 1
278
+ ;;
279
+ esac
280
+ done
@@ -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.2
67
+ VERSION=0.1.4
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.2
119
+ VERSION=0.1.4
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.2
143
+ VERSION=0.1.4
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.2
157
+ VERSION=0.1.4
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
3
+ Version: 0.1.5
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
@@ -10,7 +10,7 @@ dev = [
10
10
 
11
11
  [project]
12
12
  name = "src-py-lib"
13
- version = "0.1.3"
13
+ version = "0.1.5"
14
14
  description = "Reusable libraries for Sourcegraph projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -176,6 +176,7 @@ __all__ = [
176
176
  "load_json_cache",
177
177
  "load_json_subset",
178
178
  "logging",
179
+ "logging_context",
179
180
  "logging_settings_from_config",
180
181
  "log",
181
182
  "log_context",
@@ -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:
@@ -8,6 +8,7 @@ import json
8
8
  import queue
9
9
  import time
10
10
  from collections.abc import Iterable, Iterator, Mapping, Sequence
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
12
  from dataclasses import dataclass, field
12
13
  from typing import Final, cast
13
14
  from urllib.parse import urlsplit
@@ -19,6 +20,7 @@ from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_lis
19
20
  from src_py_lib.utils.logging import (
20
21
  current_trace_context,
21
22
  new_trace_context,
23
+ submit_with_log_context,
22
24
  trace_context_from_traceparent,
23
25
  traceparent_header,
24
26
  )
@@ -180,6 +182,9 @@ class SourcegraphClient:
180
182
  `endpoint` should be the instance base URL, for example
181
183
  `https://sourcegraph.example.com`.
182
184
 
185
+ Plain HTTP endpoints are rejected unless `allow_insecure_http=True` is set
186
+ for local development.
187
+
183
188
  Set `trace=True` to ask Sourcegraph to retain traces for each GraphQL
184
189
  request. Traced requests are available through `drain_traces()` and can be
185
190
  fetched from the instance's Jaeger/debug endpoint with
@@ -190,12 +195,16 @@ class SourcegraphClient:
190
195
  token: str
191
196
  http: HTTPClient = field(default_factory=HTTPClient)
192
197
  trace: bool = False
198
+ allow_insecure_http: bool = False
193
199
  _traces: queue.Queue[SourcegraphTrace] = field(
194
200
  default_factory=lambda: queue.Queue[SourcegraphTrace](), init=False, repr=False
195
201
  )
196
202
 
197
203
  def __post_init__(self) -> None:
198
- self.endpoint = normalize_sourcegraph_endpoint(self.endpoint)
204
+ self.endpoint = normalize_sourcegraph_endpoint(
205
+ self.endpoint,
206
+ require_https=not self.allow_insecure_http,
207
+ )
199
208
 
200
209
  def graphql(self, query: str, variables: Mapping[str, JSONValue] | None = None) -> JSONDict:
201
210
  return self._client().execute(query, variables)
@@ -244,13 +253,27 @@ class SourcegraphClient:
244
253
  traces: Iterable[SourcegraphTrace] | None = None,
245
254
  *,
246
255
  retry_delays_seconds: Sequence[float] = JAEGER_TRACE_RETRY_DELAYS_SECONDS,
256
+ parallelism: int = 8,
247
257
  ) -> Iterator[SourcegraphJaegerTraceSummary]:
248
258
  """Yield compact Jaeger/debug summaries for traced Sourcegraph requests."""
249
- for trace in self.drain_traces() if traces is None else traces:
250
- yield self.fetch_jaeger_trace_summary(
251
- trace,
252
- retry_delays_seconds=retry_delays_seconds,
253
- )
259
+ if parallelism < 1:
260
+ raise ValueError("parallelism must be at least 1")
261
+ pending_traces = list(self.drain_traces() if traces is None else traces)
262
+ with ThreadPoolExecutor(
263
+ max_workers=parallelism,
264
+ thread_name_prefix="SourcegraphJaegerTrace",
265
+ ) as executor:
266
+ futures = [
267
+ submit_with_log_context(
268
+ executor,
269
+ self.fetch_jaeger_trace_summary,
270
+ trace,
271
+ retry_delays_seconds=retry_delays_seconds,
272
+ )
273
+ for trace in pending_traces
274
+ ]
275
+ for future in as_completed(futures):
276
+ yield future.result()
254
277
 
255
278
  def fetch_jaeger_trace_summary(
256
279
  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=_safe_url(request_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"{_safe_url(request_url)}: {body_text}",
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} {_safe_url(request_url)}: "
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} {_safe_url(url)}"
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 _safe_url(url: str) -> str:
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((split.scheme, split.netloc, split.path, split.query, ""))
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 any(fragment in key_text.lower() for fragment in SECRET_FIELD_FRAGMENTS):
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] = value
945
+ headers[key] = logged_value
943
946
  elif isinstance(existing, list):
944
- existing.append(value)
947
+ existing.append(logged_value)
945
948
  else:
946
- headers[key] = [existing, value]
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"
@@ -8,6 +8,7 @@ import json
8
8
  import logging
9
9
  import subprocess
10
10
  import tempfile
11
+ import threading
11
12
  import unittest
12
13
  from collections.abc import Mapping
13
14
  from contextlib import redirect_stderr, redirect_stdout
@@ -36,6 +37,7 @@ from src_py_lib.clients.slack import SlackClient
36
37
  from src_py_lib.clients.sourcegraph import (
37
38
  SourcegraphClient,
38
39
  SourcegraphClientConfig,
40
+ SourcegraphTrace,
39
41
  decode_external_service_id,
40
42
  decode_repository_id,
41
43
  encode_repository_id,
@@ -1207,7 +1209,8 @@ class LoggingTest(unittest.TestCase):
1207
1209
  "receive_response_headers.complete "
1208
1210
  "return_value=(b'HTTP/1.1', 200, b'OK', "
1209
1211
  "[(b'Zed', b'last'), (b'Content-Type', b'application/json'), "
1210
- "(b'Alpha', b'first')])"
1212
+ "(b'Set-Cookie', b'session=secret'), "
1213
+ "(b'X-Api-Key', b'secret'), (b'Alpha', b'first')])"
1211
1214
  )
1212
1215
  finally:
1213
1216
  logger = logging.getLogger(logger_name)
@@ -1224,12 +1227,17 @@ class LoggingTest(unittest.TestCase):
1224
1227
  self.assertEqual(response_headers["http_version"], "HTTP/1.1")
1225
1228
  self.assertEqual(response_headers["status_code"], 200)
1226
1229
  self.assertEqual(response_headers["reason_phrase"], "OK")
1227
- self.assertEqual(list(response_headers["headers"]), ["alpha", "content-type", "zed"])
1230
+ self.assertEqual(
1231
+ list(response_headers["headers"]),
1232
+ ["alpha", "content-type", "set-cookie", "x-api-key", "zed"],
1233
+ )
1228
1234
  self.assertEqual(
1229
1235
  response_headers["headers"],
1230
1236
  {
1231
1237
  "alpha": "first",
1232
1238
  "content-type": "application/json",
1239
+ "set-cookie": "[redacted]",
1240
+ "x-api-key": "[redacted]",
1233
1241
  "zed": "last",
1234
1242
  },
1235
1243
  )
@@ -1309,8 +1317,9 @@ class HTTPClientTest(unittest.TestCase):
1309
1317
  client = HTTPClient(max_attempts=1, transport=httpx.MockTransport(handler))
1310
1318
  payload = client.json(
1311
1319
  "POST",
1312
- "https://example.com/api",
1320
+ "https://user:pass@example.com/api?code=oauth",
1313
1321
  headers={"Authorization": "Bearer token"},
1322
+ query={"limit": 10, "access_token": "secret", "signature": "signed"},
1314
1323
  json_body={"hello": "world"},
1315
1324
  )
1316
1325
  finally:
@@ -1330,6 +1339,11 @@ class HTTPClientTest(unittest.TestCase):
1330
1339
  self.assertFalse(any(row.get("logger") in {"httpx", "httpcore"} for row in rows))
1331
1340
  self.assertEqual(http_request["status_code"], 200)
1332
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
+ )
1333
1347
  self.assertEqual(http_request["request_bytes"], len(b'{"hello": "world"}'))
1334
1348
  self.assertEqual(http_request["request_headers"]["authorization"], "[redacted]")
1335
1349
  self.assertEqual(
@@ -1362,10 +1376,13 @@ class HTTPClientTest(unittest.TestCase):
1362
1376
  )
1363
1377
 
1364
1378
  with self.assertRaisesRegex(HTTPClientError, "rate limited") as raised:
1365
- client.json("GET", "https://example.com/api")
1379
+ client.json("GET", "https://user:pass@example.com/api?access_token=secret")
1366
1380
 
1367
1381
  self.assertEqual(raised.exception.status_code, 429)
1368
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))
1369
1386
 
1370
1387
 
1371
1388
  class ClientTest(unittest.TestCase):
@@ -1407,6 +1424,13 @@ class ClientTest(unittest.TestCase):
1407
1424
  self.assertEqual(http.calls[0]["url"], "https://sourcegraph.example.com/.api/graphql")
1408
1425
  self.assertEqual(http.calls[0]["headers"], {"Authorization": "token token"})
1409
1426
 
1427
+ def test_sourcegraph_client_rejects_http_endpoint_by_default(self) -> None:
1428
+ with self.assertRaisesRegex(ValueError, "https:// URL"):
1429
+ SourcegraphClient("http://sourcegraph.example.com", "token")
1430
+
1431
+ client = SourcegraphClient("http://localhost:3080", "token", allow_insecure_http=True)
1432
+ self.assertEqual(client.endpoint, "http://localhost:3080")
1433
+
1410
1434
  def test_sourcegraph_client_streams_connection_nodes(self) -> None:
1411
1435
  http = RecordingHTTP(
1412
1436
  [
@@ -1535,6 +1559,50 @@ class ClientTest(unittest.TestCase):
1535
1559
  self.assertEqual(summaries[0].graphql_operations[0]["operation"], "Viewer")
1536
1560
  self.assertEqual(summaries[0].errored_spans[0]["description"], "boom")
1537
1561
 
1562
+ def test_sourcegraph_streams_jaeger_summaries_in_parallel(self) -> None:
1563
+ trace_ids = ("1" * 32, "2" * 32, "3" * 32)
1564
+ requested_trace_ids: list[str] = []
1565
+ first_batch_barrier = threading.Barrier(2, timeout=1)
1566
+
1567
+ def handler(request: httpx.Request) -> httpx.Response:
1568
+ trace_id = request.url.path.rsplit("/", 1)[-1]
1569
+ requested_trace_ids.append(trace_id)
1570
+ if trace_id in trace_ids[:2]:
1571
+ first_batch_barrier.wait()
1572
+ return httpx.Response(
1573
+ 200,
1574
+ json={
1575
+ "data": [
1576
+ {
1577
+ "spans": [
1578
+ {
1579
+ "operationName": f"trace {trace_id[0]}",
1580
+ "duration": 1_000,
1581
+ "tags": [],
1582
+ }
1583
+ ]
1584
+ }
1585
+ ]
1586
+ },
1587
+ )
1588
+
1589
+ client = SourcegraphClient(
1590
+ "https://sourcegraph.example.com/",
1591
+ "token",
1592
+ http=HTTPClient(max_attempts=1, transport=httpx.MockTransport(handler)),
1593
+ )
1594
+
1595
+ summaries = list(
1596
+ client.stream_jaeger_trace_summaries(
1597
+ [SourcegraphTrace(trace_id) for trace_id in trace_ids],
1598
+ retry_delays_seconds=(0,),
1599
+ parallelism=2,
1600
+ )
1601
+ )
1602
+
1603
+ self.assertCountEqual(requested_trace_ids, trace_ids)
1604
+ self.assertCountEqual([summary.trace.trace_id for summary in summaries], trace_ids)
1605
+
1538
1606
  def test_graphql_client_paginates_cursor_results(self) -> None:
1539
1607
  http = RecordingHTTP(
1540
1608
  [
@@ -1734,7 +1802,12 @@ query Items($first: Int!, $after: String, $userId: ID!) {
1734
1802
  },
1735
1803
  ]
1736
1804
  )
1737
- client = GraphQLClient("https://example.com/graphql", {}, "Example", http=http)
1805
+ client = GraphQLClient(
1806
+ "https://user:pass@example.com/graphql?access_token=secret&query=ok",
1807
+ {},
1808
+ "Example",
1809
+ http=http,
1810
+ )
1738
1811
  query = """
1739
1812
  query Items($first: Int!, $after: String, $userId: ID!) {
1740
1813
  viewer { items { nodes { id } pageInfo { hasNextPage endCursor } } }
@@ -1776,6 +1849,10 @@ query Items($first: Int!, $after: String, $userId: ID!) {
1776
1849
  self.assertEqual([row["page_size"] for row in starts], [2, 2])
1777
1850
  self.assertEqual([row["cursor_present"] for row in starts], [False, True])
1778
1851
  self.assertEqual(starts[0]["graphql_client"], "Example")
1852
+ self.assertEqual(
1853
+ starts[0]["url"],
1854
+ "https://example.com/graphql?access_token=[redacted]&query=ok",
1855
+ )
1779
1856
  self.assertEqual(starts[0]["variable_names"], ["after", "first", "userId"])
1780
1857
  self.assertEqual(ends[0]["response_fields"], ["viewer"])
1781
1858
 
@@ -1900,6 +1977,10 @@ query Items($first: Int!, $after: String) {
1900
1977
  graphql_api_url("github.example.com"), "https://github.example.com/api/graphql"
1901
1978
  )
1902
1979
 
1980
+ def test_github_client_rejects_http_enterprise_url(self) -> None:
1981
+ with self.assertRaisesRegex(ValueError, "https:// URL"):
1982
+ graphql_api_url("http://github.example.com")
1983
+
1903
1984
  def test_github_client_validate_queries_viewer(self) -> None:
1904
1985
  http = RecordingHTTP([{"data": {"viewer": {"login": "alice"}}}])
1905
1986
  client = GitHubClient("token", http=http)
@@ -254,7 +254,7 @@ wheels = [
254
254
 
255
255
  [[package]]
256
256
  name = "src-py-lib"
257
- version = "0.1.3"
257
+ version = "0.1.5"
258
258
  source = { editable = "." }
259
259
  dependencies = [
260
260
  { name = "httpx" },
@@ -1,135 +0,0 @@
1
- name: Validate
2
-
3
- on:
4
- workflow_call:
5
- inputs:
6
- ref:
7
- description: "Git ref to validate. Defaults to the caller's ref."
8
- required: false
9
- type: string
10
- build-package:
11
- description: "Build and smoke-test package artifacts. Release builds do this separately."
12
- required: false
13
- type: boolean
14
- default: true
15
-
16
- permissions:
17
- contents: read
18
-
19
- defaults:
20
- run:
21
- shell: bash
22
-
23
- jobs:
24
- package:
25
- name: Validate package
26
- runs-on: ubuntu-24.04
27
- env:
28
- ACTIONLINT_VERSION: "1.7.12"
29
- IMPORT_NAME: src_py_lib
30
- MARKDOWNLINT_CLI2_VERSION: "0.22.1"
31
- PYTHON_VERSION: "3.11"
32
- UV_VERSION: "0.11.7"
33
-
34
- steps:
35
- - name: Check out code
36
- uses: actions/checkout@v6
37
- with:
38
- persist-credentials: false
39
- ref: ${{ inputs.ref || github.ref }}
40
-
41
- - name: Cache actionlint
42
- id: cache-actionlint
43
- uses: actions/cache@v5
44
- with:
45
- path: ~/.local/bin/actionlint
46
- key: actionlint-${{ runner.os }}-${{ runner.arch }}-${{ env.ACTIONLINT_VERSION }}
47
-
48
- - name: Install actionlint
49
- if: steps.cache-actionlint.outputs.cache-hit != 'true'
50
- run: |
51
- mkdir -p "${HOME}/.local/bin"
52
- asset="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
53
- checksums="actionlint_${ACTIONLINT_VERSION}_checksums.txt"
54
- base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
55
-
56
- curl -fsSLO "${base_url}/${asset}"
57
- curl -fsSLO "${base_url}/${checksums}"
58
- grep " ${asset}$" "${checksums}" | sha256sum --check
59
- tar -xzf "${asset}" -C "${HOME}/.local/bin" actionlint
60
- chmod 0755 "${HOME}/.local/bin/actionlint"
61
-
62
- - name: Lint GitHub Actions
63
- run: |
64
- "${HOME}/.local/bin/actionlint"
65
-
66
- - name: Cache npm
67
- uses: actions/cache@v5
68
- with:
69
- path: ~/.npm
70
- key: npm-${{ runner.os }}-markdownlint-cli2-${{ env.MARKDOWNLINT_CLI2_VERSION }}
71
-
72
- - name: Lint Markdown
73
- run: npx --yes "markdownlint-cli2@${MARKDOWNLINT_CLI2_VERSION}"
74
-
75
- - name: Set up Python
76
- uses: actions/setup-python@v6
77
- with:
78
- python-version: ${{ env.PYTHON_VERSION }}
79
- cache: pip
80
-
81
- - name: Cache uv
82
- uses: actions/cache@v5
83
- with:
84
- path: ~/.cache/uv
85
- key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
86
- restore-keys: |
87
- uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-
88
-
89
- - name: Install uv
90
- run: python -m pip install "uv==${UV_VERSION}"
91
-
92
- - name: Validate lockfile
93
- run: uv lock --check
94
-
95
- - name: Lint Python
96
- run: uv run --frozen ruff check .
97
-
98
- - name: Check Python formatting
99
- run: uv run --frozen ruff format --check .
100
-
101
- - name: Type check
102
- run: uv run --frozen pyright
103
-
104
- - name: Run tests
105
- run: uv run --frozen python -m unittest discover -s tests
106
-
107
- - name: Smoke test source checkout import
108
- run: |
109
- uv run --frozen python - <<'PY'
110
- import os
111
-
112
- import src_py_lib
113
-
114
- if src_py_lib.__name__ != os.environ["IMPORT_NAME"]:
115
- raise SystemExit(f"unexpected import name: {src_py_lib.__name__}")
116
- PY
117
-
118
- - name: Build wheel
119
- if: inputs.build-package
120
- run: uv build --wheel --out-dir dist --no-create-gitignore
121
-
122
- - name: Smoke test installed wheel
123
- if: inputs.build-package
124
- run: |
125
- python -m venv build/ci-venv
126
- . build/ci-venv/bin/activate
127
- python -m pip install dist/*.whl
128
- python - <<'PY'
129
- import os
130
-
131
- import src_py_lib
132
-
133
- if src_py_lib.__name__ != os.environ["IMPORT_NAME"]:
134
- raise SystemExit(f"unexpected import name: {src_py_lib.__name__}")
135
- PY
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes