tapbench 0.1.0__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.
@@ -0,0 +1,220 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .env.mppdb
153
+ .env.mppdb~
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
201
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
203
+ # you could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
tapbench-0.1.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Mario Juric
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,313 @@
1
+ Metadata-Version: 2.4
2
+ Name: tapbench
3
+ Version: 0.1.0
4
+ Summary: A small fixed-rate benchmark client for TAP asynchronous jobs
5
+ Project-URL: Homepage, https://github.com/mjuric/tapbench
6
+ Project-URL: Repository, https://github.com/mjuric/tapbench
7
+ Project-URL: Issues, https://github.com/mjuric/tapbench/issues
8
+ Author-email: Mario Juric <mjuric@uw.edu>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Keywords: ADQL,IVOA,TAP,UWS,benchmark
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: aiohttp<4,>=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # tapbench
26
+
27
+ `tapbench` is a small fixed-rate benchmark client for IVOA TAP asynchronous
28
+ jobs. It schedules one ADQL query at an offered rate, follows each admitted UWS
29
+ job to a terminal phase, optionally streams its primary result, and reports
30
+ latency and throughput. It is intentionally not a general-purpose load-testing
31
+ framework.
32
+
33
+ Python 3.12 or newer is required.
34
+
35
+ ## Install
36
+
37
+ Install the command from PyPI with either `uv` or `pipx`:
38
+
39
+ ```bash
40
+ uv tool install tapbench
41
+ # or
42
+ pipx install tapbench
43
+ ```
44
+
45
+ From a checkout, [uv](https://docs.astral.sh/uv/) can create the development
46
+ environment and run the command:
47
+
48
+ ```bash
49
+ uv sync
50
+ uv run tapbench --help
51
+ ```
52
+
53
+ The service credentials must already be present in the process environment as
54
+ `TAPBENCH_USER` and `TAPBENCH_PASSWORD`. Both must be nonempty. The command does
55
+ not read dotenv files and does not accept credentials as command-line options.
56
+
57
+ ## Run a benchmark
58
+
59
+ ```bash
60
+ uv run tapbench run \
61
+ --url https://example.org/tap \
62
+ --query-file examples/fast_lookup.sql \
63
+ --rate 20 \
64
+ --duration 60 \
65
+ --max-inflight 200 \
66
+ --poll-interval 0.5 \
67
+ --no-download-results
68
+ ```
69
+
70
+ `--url` is the TAP base URL; `tapbench` appends `/async`. `--query-file` must
71
+ contain exactly the ADQL query offered at every opportunity. The query itself
72
+ is never printed or stored in JSON; the report identifies it by file name/path
73
+ and SHA-256 digest.
74
+
75
+ The `run` options are:
76
+
77
+ | Option | Meaning |
78
+ | --- | --- |
79
+ | `--url URL` | TAP base URL. Only HTTP(S) URLs without credentials, query text, or fragments are accepted. |
80
+ | `--query-file FILE` | Readable, nonempty file containing one ADQL query. |
81
+ | `--rate FLOAT` | Submission opportunities per second; must be positive and finite. |
82
+ | `--duration FLOAT` | Time during which new opportunities are scheduled; must be positive and finite. |
83
+ | `--max-inflight INTEGER` | Maximum admitted operations whose full local lifecycle has not finished; must be at least one. |
84
+ | `--poll-interval FLOAT` | Approximate phase-poll interval, with reproducible small jitter. |
85
+ | `--request-timeout FLOAT` | Timeout for one HTTP operation, including a result-body operation or cleanup request. |
86
+ | `--job-timeout FLOAT` | Time allowed after job creation to observe a terminal UWS phase. |
87
+ | `--download-results`, `--no-download-results` | Stream completed primary results, or stop after observing `COMPLETED`; downloading is the default. |
88
+ | `--keep-jobs` | Do not delete remote UWS jobs after processing. |
89
+ | `--json-output FILE` | Atomically write the detailed `tapbench.run/v1` report. |
90
+ | `--seed INTEGER` | Seed polling jitter for reproducible runs. |
91
+ | `--progress`, `--no-progress` | Enable or suppress live stderr progress; progress is enabled by default. |
92
+
93
+ Run `uv run tapbench run --help` for the installed command's defaults.
94
+
95
+ ## Fixed-rate behavior
96
+
97
+ This is an open-loop offered-rate benchmark. Opportunity `k` has an absolute
98
+ deadline of `k / rate` seconds from the monotonic scheduling epoch, beginning
99
+ with opportunity zero at `t=0`. All deadlines strictly before `duration` are
100
+ offered. Thus `--rate 10 --duration 10` creates exactly 100 opportunities even
101
+ when earlier queries are slow. Polls and result requests do not count toward
102
+ the offered rate, and a late event-loop wake-up does not shift later deadlines.
103
+
104
+ An opportunity is admitted only if an in-flight slot is immediately available.
105
+ Otherwise it is recorded as `local_scheduling_drop`; it is never queued to run
106
+ later. A `--max-inflight` slot is held from submission start through job
107
+ creation, phase handling, optional result download, and attempted cleanup.
108
+ Consequently, slow downloads or cleanup can legitimately cause later drops.
109
+ This bounds local tasks and network work as well as remote nonterminal jobs.
110
+
111
+ After the scheduling interval, `tapbench` drains the bounded set of admitted
112
+ operations. The job timeout covers waiting for a terminal phase, beginning
113
+ after successful job creation. Result download and cleanup occur outside that
114
+ job deadline and are instead bounded by per-operation request timeouts.
115
+
116
+ Job creation, job start, phase polling, and remote cleanup treat an explicit
117
+ HTTP 429 response as transient server backpressure through one shared retry
118
+ policy. `tapbench` honors a valid `Retry-After` delay (seconds or HTTP date),
119
+ adds small jitter to avoid synchronized retries, and otherwise uses bounded
120
+ jittered exponential backoff. Creation retries remain within the request
121
+ timeout, start and polling retries remain within the original job deadline, and
122
+ DELETE retries remain within a separate cleanup deadline. Ambiguous POST
123
+ timeouts and transport failures are not retried. Each record and the terminal
124
+ summary report stage-specific rate-limit counts and cumulative wait times.
125
+
126
+ After observing a terminal phase, `tapbench` retrieves the standard UWS job
127
+ document before cleanup and reads `creationTime`, `startTime`, and `endTime`.
128
+ It reports query queue duration as `startTime - creationTime` and query
129
+ execution duration as `endTime - startTime`. Timing retrieval is best effort:
130
+ missing, malformed, or unavailable fields remain null and do not change the
131
+ job outcome. A service's timestamp precision limits these measurements; a
132
+ whole-second service may report zero for a subsecond query. The queue duration
133
+ also includes any time the job remained PENDING before the client sent RUN, so
134
+ it is not necessarily pure database scheduler wait.
135
+
136
+ ## Results and success
137
+
138
+ By default, a `COMPLETED` job is successful only after
139
+ `{job}/results/result` has been completely consumed and no HTTP, TAP, or
140
+ VOTable result error was detected. Response-header, first-byte, complete-body,
141
+ and submission-to-download timings are recorded along with the byte count. The
142
+ body is streamed rather than retained in memory.
143
+
144
+ With `--no-download-results`, a job is successful as soon as creation succeeds
145
+ and `COMPLETED` is observed. No result-list, result-resource, redirect-target,
146
+ or size-discovery request is made, and all result timing and byte fields are
147
+ null. Cleanup failure is reported separately and does not change an otherwise
148
+ successful operation.
149
+
150
+ Result inspection is deliberately bounded and does not deserialize a complete
151
+ VOTable. `tapbench` retains only a small prefix while streaming and detects a
152
+ `QUERY_STATUS=ERROR` marker when it appears there. An error marker appearing
153
+ only beyond that inspection bound cannot be detected.
154
+
155
+ Latency summaries ignore missing observations and use nearest-rank p50, p90,
156
+ p95, and p99 values: sort the samples and select observation
157
+ `ceil(percentile * count)`. An unavailable metric is shown as `unavailable`
158
+ and serialized as JSON `null`.
159
+
160
+ Throughput labels have distinct denominators:
161
+
162
+ - achieved submission rate is submission starts divided by the configured
163
+ scheduling duration, or by elapsed scheduler-active time after interruption;
164
+ - job-creation throughput is created jobs divided by the time from the
165
+ scheduling epoch through the last successful creation response;
166
+ - server execution throughput is completed jobs with valid UWS timing divided
167
+ by the server work window from the earliest `startTime` to the latest
168
+ `endTime`; it is independent of client polling and unavailable when the
169
+ server timestamps produce a zero-width window;
170
+ - result-completion throughput, when downloading, is successful downloads
171
+ divided by the download window from the first download start through the
172
+ last completion;
173
+ - download throughput is successfully downloaded bytes divided by the span
174
+ of that same download window. Download rates are unavailable when there are
175
+ no successful downloads or the span is zero.
176
+
177
+ No throughput denominator includes phase-poll sleeps or post-completion cleanup.
178
+ The report shows how many completed jobs supplied valid UWS timing because
179
+ server execution throughput may use fewer samples than the completed count.
180
+
181
+ ## Progress and terminal output
182
+
183
+ Progress is operational telemetry; final metrics are recomputed from all
184
+ records. It is written to stderr so the final human summary remains on stdout.
185
+ On a terminal, one line refreshes about once per second:
186
+
187
+ ```text
188
+ [schedule 12.0/60.0s] offered=241 started=238 dropped=3 active=87 created=235 completed=141 error=2 aborted=0 success=139 failed=5 polls=812 submission_rate_limited=4 start_rate_limited=2 rate_limited=11 cleanup_rate_limited=3 downloaded=812 MiB
189
+ ```
190
+
191
+ Once scheduling ends, the label changes rather than implying submissions are
192
+ continuing:
193
+
194
+ ```text
195
+ [drain 72.4s] offered=1200 started=1184 dropped=16 active=87 created=1179 completed=1068 error=9 aborted=1 success=1059 failed=26 polls=6312 submission_rate_limited=18 start_rate_limited=9 rate_limited=47 cleanup_rate_limited=12 downloaded=3.1 GiB
196
+ ```
197
+
198
+ The `downloaded` field is omitted in no-download mode, and stage-specific rate
199
+ limit fields are omitted until nonzero. When stderr is not a TTY, searchable
200
+ `tapbench progress:` snapshots are emitted at most every five seconds, plus one
201
+ at the schedule-to-drain transition. `--no-progress` suppresses all snapshots
202
+ but not the final summary.
203
+
204
+ The final stdout report includes opportunity, submission, terminal, success,
205
+ failure, HTTP, poll, and byte counts; peak in-flight work; the separate
206
+ throughput values; client-observed and UWS server-side latency percentiles; and
207
+ a primary-failure breakdown. Result rows are omitted in no-download mode.
208
+
209
+ ## JSON output and exit status
210
+
211
+ `--json-output` records sanitized configuration, UTC correlation timestamps,
212
+ monotonic durations, query identity, aggregates, and one structured record for
213
+ every opportunity, including local drops. Its schema id is `tapbench.run/v1`.
214
+ Enums are strings and unavailable observations are null. Output is flushed and
215
+ atomically replaces the destination, so a failed write does not leave a
216
+ partially encoded report. Per-job records retain the three raw UWS timestamp
217
+ strings alongside the derived queue and execution durations.
218
+
219
+ Exit codes are:
220
+
221
+ | Code | Meaning |
222
+ | --- | --- |
223
+ | `0` | The benchmark completed. Individual query failures and local drops are measurements, not process failures. |
224
+ | `2` | Invalid CLI/configuration or missing credentials. No benchmark was started. |
225
+ | `3` | Interrupted partial run. |
226
+ | `4` | A fatal run-level client or output error prevented a valid run. |
227
+
228
+ On the first SIGINT or SIGTERM, scheduling stops immediately, active operations
229
+ are cancelled, and already-created remote jobs receive bounded best-effort
230
+ cleanup unless `--keep-jobs` was selected. Accumulated records are retained,
231
+ the partial summary and requested JSON are produced, and the command exits 3.
232
+ A second interruption may bypass cleanup and output.
233
+
234
+ ## Security
235
+
236
+ Basic authentication is applied explicitly to every TAP/UWS request. Job,
237
+ phase, error, result, cleanup, and redirect URLs must remain on the configured
238
+ TAP origin; userinfo and cross-origin redirects are rejected. Displayed and
239
+ serialized URLs omit userinfo, query strings, and fragments, and job identifiers
240
+ are redacted. Errors are bounded and scrubbed of credential values. Neither
241
+ credentials, authorization headers, query text, nor complete job URLs belong in
242
+ terminal output or JSON.
243
+
244
+ Never commit local credential files. In particular, `.env.mppdb` and backup
245
+ variants are local secrets and are ignored by this repository.
246
+
247
+ ## APDB example queries
248
+
249
+ The queries in [`examples/`](examples/) use table and column names from the
250
+ [published Rubin APDB schema](https://sdm-schemas.lsst.io/apdb.html):
251
+
252
+ - `fast_lookup.sql` returns at most ten current object summaries;
253
+ - `selective_join.sql` returns at most 25 reliable r-band detections joined to
254
+ their current object positions;
255
+ - `expensive_small_result.sql` scans and aggregates source fluxes but returns
256
+ only one row per band.
257
+
258
+ The SQL files are included in both source and wheel distributions. In an
259
+ installed wheel they reside under the `tapbench/examples/` package directory;
260
+ the repository copies remain the most convenient paths for command-line use.
261
+
262
+ They intentionally use unqualified table names, as shown by the published APDB
263
+ schema. If a deployment exposes APDB tables only under a TAP schema qualifier,
264
+ make a local copy and qualify both table names for that deployment. `tapbench`
265
+ does not discover or rewrite schemas at runtime.
266
+
267
+ For a safe live check, first run each example once at a low rate with a single
268
+ in-flight slot and normal cleanup. Then perform a short no-download benchmark:
269
+
270
+ ```bash
271
+ uv run tapbench run \
272
+ --url https://mppdb.juriclab.org \
273
+ --query-file examples/fast_lookup.sql \
274
+ --rate 1 \
275
+ --duration 3 \
276
+ --max-inflight 1 \
277
+ --no-download-results
278
+ ```
279
+
280
+ Do not use `--keep-jobs` for routine validation. Confirm the report contains
281
+ three opportunities and no sensitive URL or credential material.
282
+
283
+ ## Development
284
+
285
+ The test suite uses a deterministic local fake TAP service; live service
286
+ behavior is not a replacement for those tests. Run all checks with:
287
+
288
+ ```bash
289
+ uv run ruff format --check .
290
+ uv run ruff check .
291
+ uv run mypy src
292
+ uv run pytest
293
+ ```
294
+
295
+ ## Releasing
296
+
297
+ Package versions are derived from Git tags. Releases use clean tags of the
298
+ form `vX.Y.Z`; for example, tag `v0.1.0` produces package version `0.1.0`.
299
+ Untagged commits produce development versions and are never published.
300
+
301
+ Pushing a release tag builds and validates the wheel and source distribution,
302
+ prints their complete file manifests, and saves them as a workflow artifact.
303
+ Publishing first pauses at the protected `testpypi` environment. After the
304
+ TestPyPI package is verified, production publishing pauses separately at the
305
+ protected `pypi` environment. Review the artifact names, hashes, and manifests
306
+ before approving either environment; the release workflow uses Trusted
307
+ Publishing and stores no PyPI token.
308
+
309
+ Before the first release, create pending Trusted Publishers on TestPyPI and
310
+ PyPI with project `tapbench`, owner `mjuric`, repository `tapbench`, workflow
311
+ `publish.yml`, and the corresponding `testpypi` or `pypi` environment. Configure
312
+ both GitHub environments with required reviewers so neither upload can begin
313
+ without explicit approval.