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.
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.github/workflows/ci.yml +1 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.github/workflows/release.yml +10 -4
- src_py_lib-0.1.5/.github/workflows/validate.yml +280 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/AGENTS.md +4 -4
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/PKG-INFO +1 -1
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/pyproject.toml +1 -1
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/__init__.py +1 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/github.py +5 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/graphql.py +2 -2
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/sourcegraph.py +29 -6
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/http.py +46 -6
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/logging.py +12 -4
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_logging_http_clients.py +86 -5
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/uv.lock +1 -1
- src_py_lib-0.1.3/.github/workflows/validate.yml +0 -135
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.gitignore +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.markdownlint-cli2.yaml +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/.python-version +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/LICENSE +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/README.md +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/SECURITY.md +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/renovate.json +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/__init__.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/google_sheets.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/linear.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/one_password.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/clients/slack.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/py.typed +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/__init__.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/config.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/json_cache.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/json_types.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/src/src_py_lib/utils/tsv.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_import.py +0 -0
- {src_py_lib-0.1.3 → src_py_lib-0.1.5}/tests/test_tsv.py +0 -0
|
@@ -12,7 +12,8 @@ on:
|
|
|
12
12
|
type: string
|
|
13
13
|
|
|
14
14
|
permissions:
|
|
15
|
-
contents:
|
|
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="${
|
|
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="${
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
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
|
|
@@ -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(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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=
|
|
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"
|
|
@@ -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'
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -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
|
|
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
|