shtoobz 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,67 @@
1
+ stages:
2
+ - verify
3
+ - publish
4
+
5
+ variables:
6
+ UV_CACHE_DIR: .uv-cache
7
+ UV_LINK_MODE: copy
8
+
9
+ .checks:
10
+ stage: verify
11
+ timeout: 20m
12
+ interruptible: true
13
+ parallel:
14
+ matrix:
15
+ - PYTHON_VERSION: ["3.11", "3.14"]
16
+ before_script:
17
+ - python -m pip install --disable-pip-version-check "uv==0.9.13"
18
+ artifacts:
19
+ name: "$CI_JOB_NAME_SLUG"
20
+ paths:
21
+ - dist/
22
+ expire_in: 1 week
23
+
24
+ linux:
25
+ extends: .checks
26
+ image: "python:${PYTHON_VERSION}-slim"
27
+ script:
28
+ - python -m uv sync --locked --python "$PYTHON_VERSION"
29
+ - python -m uv run --locked mypy
30
+ - python -m uv build --no-sources
31
+ - python -m uv run --no-cache --isolated --no-project --python "$PYTHON_VERSION" --with ./dist/*.whl python tests/check_distribution.py
32
+ - python -m uv run --no-cache --isolated --no-project --python "$PYTHON_VERSION" --with ./dist/*.whl python -W error::ResourceWarning -m unittest discover -s tests -q
33
+
34
+ windows:
35
+ extends: .checks
36
+ tags:
37
+ - saas-windows-medium-amd64
38
+ script:
39
+ - python -m uv sync --locked --python $env:PYTHON_VERSION
40
+ - python -m uv run --locked mypy
41
+ - python -m uv build --no-sources
42
+ - $wheel = (Get-ChildItem dist/*.whl).FullName
43
+ - python -m uv run --no-cache --isolated --no-project --python $env:PYTHON_VERSION --with $wheel python tests/check_distribution.py
44
+ - python -m uv run --no-cache --isolated --no-project --python $env:PYTHON_VERSION --with $wheel python -W error::ResourceWarning -m unittest discover -s tests -q
45
+
46
+ # Publish the exact artifacts already tested by linux: [3.11]. Stage ordering
47
+ # also requires every other Windows/Linux verification job to pass first.
48
+ publish:
49
+ stage: publish
50
+ image: python:3.11-slim
51
+ timeout: 10m
52
+ interruptible: false
53
+ dependencies:
54
+ - "linux: [3.11]"
55
+ rules:
56
+ - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ && $CI_PROJECT_PATH == "stolenfootball-tools/shtoobz"'
57
+ environment:
58
+ name: pypi
59
+ url: https://pypi.org/project/shtoobz/
60
+ id_tokens:
61
+ PYPI_ID_TOKEN:
62
+ aud: pypi
63
+ before_script:
64
+ - python -m pip install --disable-pip-version-check "uv==0.9.13"
65
+ script:
66
+ - python tests/check_release.py "$CI_COMMIT_TAG"
67
+ - python -m uv publish --trusted-publishing always --publish-url https://upload.pypi.org/legacy/ --check-url https://pypi.org/simple/ dist/*.whl dist/*.tar.gz
shtoobz-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeremy Dunn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
shtoobz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,405 @@
1
+ Metadata-Version: 2.4
2
+ Name: shtoobz
3
+ Version: 0.1.0
4
+ Summary: Lightweight interchangeable process and TCP byte streams for Windows and Linux.
5
+ Author: Jeremy Dunn
6
+ Author-email: Jeremy Dunn <jeremy.dunn315@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Project-URL: Documentation, https://gitlab.com/stolenfootball-tools/shtoobz/-/blob/main/README.md
21
+ Project-URL: Issues, https://gitlab.com/stolenfootball-tools/shtoobz/-/issues
22
+ Project-URL: Repository, https://gitlab.com/stolenfootball-tools/shtoobz
23
+ Description-Content-Type: text/markdown
24
+
25
+ # shtoobz
26
+
27
+ Lightweight, interchangeable byte streams for Windows and Linux. A **shtoob**
28
+ talks to a child process over stdin/stdout or to an application over TCP. The
29
+ client's communication code stays the same when you switch transports.
30
+
31
+ Python 3.11+; no third-party runtime dependencies. Managed and built with uv.
32
+
33
+ ## Development
34
+
35
+ ```sh
36
+ uv sync --locked
37
+ uv run python -m unittest discover -s tests -v
38
+ uv run mypy
39
+ uv build
40
+ ```
41
+
42
+ The build produces a wheel and source archive in `dist/`. To use the checkout
43
+ from another uv project, run `uv add /path/to/shtoobz`. No published package is
44
+ required.
45
+
46
+ GitLab CI verifies Windows and Linux on Python 3.11 and 3.14. Each job runs
47
+ strict type checks, builds the distributions, validates their metadata and
48
+ contents, and runs the integration suite against an isolated wheel installation.
49
+ The Windows jobs use GitLab's `saas-windows-medium-amd64` hosted runner tag; enable
50
+ hosted runners for the project (or substitute your own Windows runner tag).
51
+ Branch pipelines save build artifacts. A matching `vX.Y.Z` tag in the canonical
52
+ GitLab project publishes to PyPI after all verification jobs pass; see the release
53
+ steps below.
54
+
55
+ The source archive includes the tests, lockfile, CI configuration, and MIT
56
+ license. The installed library has no runtime dependencies; mypy is a development
57
+ dependency only.
58
+
59
+ ## Switch transports
60
+
61
+ ```python
62
+ from shtoobz import ProcessShtoob, SocketShtoob
63
+
64
+ LOCAL = True
65
+
66
+ shtoob = (
67
+ ProcessShtoob(["./example", "--stdio"], timeout=5)
68
+ if LOCAL
69
+ else SocketShtoob("127.0.0.1", 9000, timeout=5)
70
+ )
71
+
72
+ with shtoob:
73
+ shtoob.recvuntil(b"Name: ")
74
+ shtoob.sendline(b"Alice")
75
+ print(shtoob.recvline())
76
+ ```
77
+
78
+ Use the appropriate executable path on Windows, for example `r".\example.exe"`.
79
+ Both endpoints must expose the same byte protocol; shtoobz does not translate
80
+ between a CLI menu and a different network protocol.
81
+
82
+ A runnable process example on both platforms:
83
+
84
+ ```python
85
+ import sys
86
+ from shtoobz import ProcessShtoob
87
+
88
+ with ProcessShtoob(
89
+ [sys.executable, "-u", "-c", "print(input().upper(), flush=True)"],
90
+ timeout=5,
91
+ ) as shtoob:
92
+ shtoob.sendline(b"hello")
93
+ assert shtoob.recvline().rstrip(b"\r\n") == b"HELLO"
94
+ ```
95
+
96
+ Inject a shtoob into your application instead of constructing a socket inside it:
97
+
98
+ ```python
99
+ from shtoobz import Shtoob
100
+
101
+ class Client:
102
+ def __init__(self, shtoob: Shtoob):
103
+ self.shtoob = shtoob
104
+
105
+ def request(self, payload: bytes) -> bytes:
106
+ self.shtoob.sendline(payload)
107
+ return self.shtoob.recvline()
108
+ ```
109
+
110
+ ## Communication API
111
+
112
+ All payloads and delimiters are `bytes`. Encode/decode text explicitly.
113
+
114
+ | Method | Behavior |
115
+ | --- | --- |
116
+ | `recv(n=4096)` | Read up to `n` bytes, waiting only if no data is available. Return `b""` at EOF. |
117
+ | `recvn(n)` | Read exactly `n` bytes or raise `ShtoobEOF`/`ShtoobTimeout`. |
118
+ | `peek(n=4096)` | Inspect up to `n` bytes without consuming them; wait only if the buffer is empty. |
119
+ | `unrecv(data)` | Prepend bytes to the receive buffer. |
120
+ | `recvuntil(delimiter)` | Read through and include the delimiter. |
121
+ | `recvany(*delimiters)` | Read through the earliest completed delimiter (the shortest matching prefix). |
122
+ | `recvline()` | Read through and include the configured newline. |
123
+ | `recvall()` | Read until EOF; a short read or idle period is not EOF. |
124
+ | `recv_available()` | Collect output until an idle gap or EOF. |
125
+ | `iterlines()` | Yield lines and any unterminated final line. |
126
+ | `iterchunks(size=4096)` | Yield chunks of at most `size` bytes until EOF. |
127
+ | `sendall(data)` | Write all bytes. |
128
+ | `sendline(data=b"")` | Write data followed by the configured newline. |
129
+ | `sendafter(delimiter, data)` | Receive a prompt, send bytes, and return the prompt. |
130
+ | `sendlineafter(delimiter, data=b"")` | Receive a prompt, send a line, and return the prompt. |
131
+ | `send_eof()` | Close the sending direction while keeping responses readable. |
132
+ | `close()` | Release resources; repeated calls are harmless. |
133
+ | `closed` | Whether this shtoob has been closed locally. |
134
+ | `write_closed` | Whether the sending direction has been closed locally. |
135
+
136
+ Every receive method accepts a keyword `timeout`. If omitted, the constructor's
137
+ timeout applies. The default is `None` (wait indefinitely); an explicit `None`
138
+ overrides a finite default. Zero attempts an immediate poll. A finite timeout is
139
+ a deadline for the read operation, not an idle timeout reset by every chunk.
140
+ Writes are blocking and do not have a timeout.
141
+
142
+ Both `recv(0)` and `recvn(0)` return `b""`. Delimiters must be nonempty; negative
143
+ counts/timeouts and nonfinite timeouts are rejected. Delimiters are included in
144
+ results, and bytes received after a delimiter are retained for the next read.
145
+
146
+ On timeout, bytes already received remain buffered. On EOF, an incomplete exact
147
+ or delimiter read raises `ShtoobEOF`; its `partial` attribute contains a snapshot
148
+ of retained data. Retrieve that data with `recv()` or `recvall()`. `recvline()`
149
+ therefore raises on an unterminated final line rather than returning it silently.
150
+
151
+ ```python
152
+ from shtoobz import ShtoobTimeout
153
+
154
+ try:
155
+ reply = shtoob.recvuntil(b"> ", timeout=0.5)
156
+ except ShtoobTimeout:
157
+ # Continue the same response; previously received bytes have not been lost.
158
+ reply = shtoob.recvuntil(b"> ", timeout=5)
159
+ ```
160
+
161
+ `ShtoobTimeout` also inherits `TimeoutError`; `ShtoobEOF` also inherits `EOFError`.
162
+ Operations after local closure raise `ShtoobClosed`. These all inherit
163
+ `ShtoobError`. Other OS failures (connection refused/reset, executable not found,
164
+ permissions, etc.) retain their standard Python exceptions. A failed write may
165
+ have sent a prefix; do not retry the entire payload blindly.
166
+
167
+ ## Finish input without closing output
168
+
169
+ Some programs do not produce output until their input stream reaches EOF.
170
+ `send_eof()` closes a child's stdin or performs a TCP write shutdown, allowing
171
+ the same pattern for either transport:
172
+
173
+ ```python
174
+ shtoob.sendall(payload)
175
+ shtoob.send_eof()
176
+ result = shtoob.recvall(timeout=5, max_bytes=1024 * 1024)
177
+ ```
178
+
179
+ Repeated `send_eof()` calls are harmless while the shtoob remains open. Further
180
+ writes (including empty writes) raise `ShtoobEOF`; reads continue normally.
181
+ After `close()`, `send_eof()` raises `ShtoobClosed`. Half-closing does not terminate
182
+ a child process. It waits for any ongoing write, so it is not a way to interrupt
183
+ a blocked writer.
184
+
185
+ ## Limit accumulated responses
186
+
187
+ `recvuntil`, `recvany`, `recvline`, and `recvall` accept `max_bytes=None` by default.
188
+ Pass a nonnegative integer to bound the response, including its delimiter:
189
+
190
+ ```python
191
+ from shtoobz import ShtoobLimitExceeded
192
+
193
+ try:
194
+ line = shtoob.recvline(timeout=5, max_bytes=4096)
195
+ except ShtoobLimitExceeded as exc:
196
+ print(f"Response did not fit within {exc.max_bytes} bytes")
197
+ # Received bytes remain available; choose whether to read or close.
198
+ ```
199
+
200
+ A delimiter ending exactly at the limit succeeds. If the limit is reached
201
+ without a complete delimiter, `ShtoobLimitExceeded` is raised immediately. A
202
+ delimiter read with `max_bytes=0` therefore always raises. For `recvall`, exactly
203
+ the limit is allowed if followed by EOF; one extra byte may be read to distinguish
204
+ EOF from overflow. That lookahead byte is retained if the operation fails.
205
+ `recvall(max_bytes=0)` accepts only an empty stream followed by EOF.
206
+
207
+ Limits apply to the entire requested response, including data buffered by earlier
208
+ operations. They never discard previously buffered data. The process reader's
209
+ separate bounded buffer and OS buffers can still contain additional bytes.
210
+
211
+ ## Convenience methods
212
+
213
+ Prompt helpers return the consumed prompt, including its delimiter:
214
+
215
+ ```python
216
+ prompt = shtoob.sendlineafter(b"Name: ", b"Alice", timeout=5, max_bytes=4096)
217
+ ```
218
+
219
+ `sendafter()` sends the supplied bytes unchanged; `sendlineafter()` appends the
220
+ configured newline. Their timeout and byte limit apply to reading the prompt.
221
+ Writes remain blocking. A failed prompt read sends nothing and preserves partial
222
+ input. These helpers compose a read and write; they are not atomic transactions
223
+ against other readers or writers.
224
+
225
+ `peek(n, timeout=...)` behaves like `recv(n)` but leaves the result in the buffer.
226
+ It can return fewer than `n` bytes. `unrecv(data)` prepends bytes, so successive
227
+ calls put the most recently supplied bytes first. Both work after peer EOF until
228
+ local closure; `unrecv()` does not reopen the underlying stream. Neither changes
229
+ the bytes that have been sent to the application.
230
+
231
+ ```python
232
+ prefix = shtoob.peek(4)
233
+ header = shtoob.recvn(4)
234
+ shtoob.unrecv(header) # Let another parser consume the complete header.
235
+ ```
236
+
237
+ `recv_available(idle_timeout=0.05, timeout=..., max_bytes=None)` collects output
238
+ until no new bytes arrive for `idle_timeout` seconds or EOF occurs. It returns
239
+ `b""` if that idle period passes without data. Use `idle_timeout=0` to drain only
240
+ bytes already available; a live stream may produce more immediately afterward.
241
+ The independent `timeout` is an overall deadline using the constructor's default.
242
+ It raises `ShtoobTimeout` and preserves all data if output keeps arriving beyond
243
+ that deadline. `max_bytes` behaves like `recvall`, including its one-byte
244
+ lookahead. An idle gap is not proof of a complete application message; use a
245
+ delimiter or known length when the protocol provides one.
246
+
247
+ ```python
248
+ output = shtoob.recv_available(idle_timeout=0.1, timeout=2, max_bytes=65536)
249
+ ```
250
+
251
+ `iterlines(timeout=..., max_bytes=None)` includes each line's newline and yields
252
+ an unterminated final line at EOF. Its timeout and byte limit apply to each line.
253
+ `iterchunks(size=4096, timeout=...)` yields nonempty chunks up to the requested
254
+ positive size, with a timeout per chunk. A timeout or limit error ends that
255
+ iterator while leaving incomplete input buffered. Neither iterator holds a read
256
+ lock between yields, and stopping early leaves the shtoob open and remaining
257
+ bytes available. Use one logical consumer at a time.
258
+
259
+ ```python
260
+ for chunk in shtoob.iterchunks(8192, timeout=5):
261
+ output_file.write(chunk)
262
+ ```
263
+
264
+ ## Opt-in traffic logging
265
+
266
+ Pass a standard-library `logging.Logger` to either constructor. Without one,
267
+ shtoobz emits no traffic logs, even if the root logger has DEBUG enabled.
268
+ It never installs handlers or changes logging levels itself:
269
+
270
+ ```python
271
+ import logging
272
+ from shtoobz import SocketShtoob
273
+
274
+ logging.basicConfig(level=logging.DEBUG)
275
+ with SocketShtoob("localhost", 9000, logger=logging.getLogger("myapp.shtoobz")) as shtoob:
276
+ shtoob.sendline(b"hello")
277
+ ```
278
+
279
+ DEBUG records include the backend class, direction (`send` or `recv`), byte count,
280
+ and the complete payload as a bytes representation. Receive records describe raw
281
+ chunks entering the shared buffer; peeking, replaying buffered bytes, and
282
+ `unrecv()` do not duplicate them. Send records describe successfully completed
283
+ writes. A failed write may have sent a prefix that is not logged. Exceptions from
284
+ logging handlers are ignored so diagnostics cannot change successful I/O into an
285
+ apparent transport failure.
286
+
287
+ ## Process options and lifecycle
288
+
289
+ `ProcessShtoob(argv, *, cwd=None, env=None, stderr="inherit", timeout=None,
290
+ newline=b"\n", buffer_size=65536, logger=None)` launches immediately with `shell=False`.
291
+ Pass an argument list, not a shell command string. `env` replaces the child's
292
+ environment; use `{**os.environ, "KEY": "value"}` to extend it.
293
+
294
+ - `stderr="inherit"` keeps diagnostics out of the protocol stream (default).
295
+ - `stderr="merge"` combines stderr with stdout.
296
+ - `stderr="discard"` ignores stderr.
297
+ - `newline=b"\r\n"` configures both `sendline()` and `recvline()` for CRLF.
298
+ - `pid` and `returncode` expose process status; `returncode` is `None` while running.
299
+ - `wait(timeout=...)` waits for normal exit and returns the process exit code.
300
+
301
+ `wait()` follows the constructor's timeout by default; `wait(None)` waits
302
+ indefinitely and `wait(0)` polls. Timeout raises `ShtoobTimeout` without terminating
303
+ the child. Waiting neither consumes output nor closes the shtoob, and an exited
304
+ child's status can also be retrieved after `close()`.
305
+
306
+ The process reader retains its bounded buffer during `wait()`. A child producing
307
+ more output than its buffers can hold may block before it exits. Drain output
308
+ before waiting, or read concurrently; use a finite timeout when output volume is
309
+ unknown. A typical finite-output flow is:
310
+
311
+ ```python
312
+ shtoob.sendall(payload)
313
+ shtoob.send_eof()
314
+ output = shtoob.recvall(timeout=5, max_bytes=1024 * 1024)
315
+ exit_code = shtoob.wait(timeout=5) # ProcessShtoob only.
316
+ ```
317
+
318
+ A background reader uses a bounded buffer and backpressure for portable timed
319
+ pipe reads. An individual read still accumulates its response in memory; use
320
+ `max_bytes` or read in chunks when handling large or unbounded output.
321
+ The child must flush output promptly (for Python children, use `-u`). Bytes are
322
+ not decoded or newline-translated by shtoobz.
323
+
324
+ Use a context manager or call `close()` explicitly. Closing terminates the direct
325
+ child if still running, waits up to one second, then kills it if necessary and
326
+ reaps it. It does not terminate descendant processes. Avoid children that leave
327
+ descendants holding the pipe handles open. A normal EOF does not automatically
328
+ close the shtoob. Windows console children are launched without a new window.
329
+
330
+ ## Socket options
331
+
332
+ `SocketShtoob(host, port, *, timeout=None, connect_timeout=10, newline=b"\n", logger=None)`
333
+ connects immediately to an IPv4 or IPv6 TCP endpoint. `connect_timeout` is passed
334
+ to Python's connection helper; DNS resolution is subject to the operating
335
+ system's resolver behavior. `close()` shuts down and closes the socket.
336
+
337
+ ## Scope
338
+
339
+ One reader and one writer can operate concurrently. Reads are serialized, as are
340
+ writes; use one logical consumer so responses are not split between callers.
341
+ `close()` wakes blocked readers. No global signal handlers are installed.
342
+
343
+ This first version supports redirected process pipes and TCP. PTY/ConPTY terminal
344
+ emulation, interactive terminal forwarding, async APIs, TLS, server listeners,
345
+ and process-tree management are outside its scope.
346
+
347
+ When migrating from the original wrappers, change exact `recv(n)` calls to
348
+ `recvn(n)`, pass the target to the constructor, and call `shtoob.close()` instead
349
+ of accessing an underlying socket.
350
+
351
+ Licensed under the [MIT License](LICENSE). Copyright 2026 Jeremy Dunn.
352
+
353
+ ## Publishing to PyPI
354
+
355
+ Publishing uses GitLab OIDC trusted publishing, following the same approach as
356
+ ropinator. No PyPI API token is needed. The `publish` job uses environment `pypi`
357
+ and only runs for stable version tags (`vX.Y.Z`) in `stolenfootball-tools/shtoobz`.
358
+ All four Windows/Linux verification jobs must pass first. It uploads the exact
359
+ wheel and source archive tested by `linux: [3.11]`, without rebuilding them.
360
+ The release guard rejects a tag/version mismatch, extra or missing files, and
361
+ distribution metadata that does not match `pyproject.toml`.
362
+
363
+ ### One-time PyPI setup (web UI)
364
+
365
+ 1. Sign in to [PyPI](https://pypi.org/), using an account with a verified email
366
+ and two-factor authentication enabled.
367
+ 2. Open [Account settings > Publishing](https://pypi.org/manage/account/publishing/).
368
+ 3. Under **Add a new pending publisher**, select **GitLab** and enter:
369
+
370
+ | Field | Value |
371
+ | --- | --- |
372
+ | PyPI Project Name | `shtoobz` |
373
+ | Namespace | `stolenfootball-tools` |
374
+ | Project name | `shtoobz` |
375
+ | Top-level pipeline file path | `.gitlab-ci.yml` |
376
+ | Environment name | `pypi` |
377
+
378
+ 4. Click **Add**. The publisher stays pending until the first successful upload,
379
+ which creates the PyPI project. A pending publisher does not reserve the name.
380
+
381
+ For an existing PyPI project you own, use that project's **Manage > Publishing**
382
+ page instead of adding a pending publisher.
383
+
384
+ ### Publish version 0.1.0 (web UI)
385
+
386
+ 1. Confirm the latest `main` pipeline is green under
387
+ [Build > Pipelines](https://gitlab.com/stolenfootball-tools/shtoobz/-/pipelines).
388
+ 2. Open [Code > Tags](https://gitlab.com/stolenfootball-tools/shtoobz/-/tags) and
389
+ select **New tag**.
390
+ 3. Set **Tag name** to `v0.1.0` and **Create from** to `main` (or the exact verified
391
+ release commit). Optionally enter `Release 0.1.0` as the message.
392
+ 4. Select **Create tag**. This is the action that starts the publishing pipeline.
393
+ 5. Open the tag's pipeline. After the four verification jobs pass, **publish**
394
+ uploads the files automatically. No separate manual job or GitLab release is
395
+ required. The `pypi` environment is created by that job automatically.
396
+ 6. Verify [shtoobz on PyPI](https://pypi.org/project/shtoobz/) shows version `0.1.0`.
397
+
398
+ For subsequent releases, update the project version with `uv version`, commit the
399
+ updated metadata/lockfile, push, and create the corresponding `vX.Y.Z` tag once CI
400
+ passes. Retry a failed publish job only with the same tested artifacts; the upload
401
+ checks PyPI for already-uploaded identical files. Use a new version for changed
402
+ release files.
403
+
404
+ References: [PyPI trusted publisher setup](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/),
405
+ [GitLab tag creation](https://docs.gitlab.com/user/project/repository/tags/#from-the-ui).