screws 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.
Files changed (49) hide show
  1. screws-0.1.0/.github/workflows/ci.yml +21 -0
  2. screws-0.1.0/.github/workflows/publish.yml +47 -0
  3. screws-0.1.0/.gitignore +8 -0
  4. screws-0.1.0/LICENSE +26 -0
  5. screws-0.1.0/PKG-INFO +159 -0
  6. screws-0.1.0/README.md +119 -0
  7. screws-0.1.0/docs/RELEASING.md +21 -0
  8. screws-0.1.0/docs/superpowers/plans/2026-09-26-screws-0.1.md +611 -0
  9. screws-0.1.0/docs/superpowers/specs/2026-09-26-screws-package-design.md +469 -0
  10. screws-0.1.0/pyproject.toml +36 -0
  11. screws-0.1.0/screws/__init__.py +37 -0
  12. screws-0.1.0/screws/_version.py +1 -0
  13. screws-0.1.0/screws/aliases.py +94 -0
  14. screws-0.1.0/screws/coppelia/__init__.py +8 -0
  15. screws-0.1.0/screws/coppelia/_sim.py +119 -0
  16. screws-0.1.0/screws/coppelia/arm.py +230 -0
  17. screws-0.1.0/screws/coppelia/log.py +105 -0
  18. screws-0.1.0/screws/coppelia/scene.py +152 -0
  19. screws-0.1.0/screws/kinematics.py +214 -0
  20. screws-0.1.0/screws/robot.py +261 -0
  21. screws-0.1.0/screws/robots/__init__.py +49 -0
  22. screws-0.1.0/screws/robots/rrp.urdf +20 -0
  23. screws-0.1.0/screws/robots/ur5.urdf +114 -0
  24. screws-0.1.0/screws/se3.py +210 -0
  25. screws-0.1.0/screws/so3.py +166 -0
  26. screws-0.1.0/screws/testing.py +68 -0
  27. screws-0.1.0/screws/urdf.py +240 -0
  28. screws-0.1.0/tests/__init__.py +0 -0
  29. screws-0.1.0/tests/conftest.py +12 -0
  30. screws-0.1.0/tests/coppelia/__init__.py +0 -0
  31. screws-0.1.0/tests/coppelia/fake_sim.py +253 -0
  32. screws-0.1.0/tests/coppelia/test_arm.py +150 -0
  33. screws-0.1.0/tests/coppelia/test_conversions.py +68 -0
  34. screws-0.1.0/tests/coppelia/test_live_ur5.py +77 -0
  35. screws-0.1.0/tests/coppelia/test_scene.py +121 -0
  36. screws-0.1.0/tests/mr_examples.py +142 -0
  37. screws-0.1.0/tests/test_aliases.py +62 -0
  38. screws-0.1.0/tests/test_import.py +11 -0
  39. screws-0.1.0/tests/test_kinematics.py +108 -0
  40. screws-0.1.0/tests/test_mr_examples.py +50 -0
  41. screws-0.1.0/tests/test_readme_table.py +20 -0
  42. screws-0.1.0/tests/test_robot.py +95 -0
  43. screws-0.1.0/tests/test_se3.py +96 -0
  44. screws-0.1.0/tests/test_so3.py +85 -0
  45. screws-0.1.0/tests/test_testing.py +41 -0
  46. screws-0.1.0/tests/test_urdf.py +113 -0
  47. screws-0.1.0/tools/harvest_mr_examples.py +53 -0
  48. screws-0.1.0/tools/print_alias_table.py +13 -0
  49. screws-0.1.0/uv.lock +1489 -0
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: uv sync --all-extras
20
+ - run: uv run ruff check .
21
+ - run: uv run pytest -q
@@ -0,0 +1,47 @@
1
+ name: Publish to PyPI
2
+
3
+ # Runs when a version tag is pushed (git tag v0.1.0 && git push --tags).
4
+ # Publishes with PyPI trusted publishing (OIDC) when the project has this workflow
5
+ # registered as a publisher; otherwise falls back to the PYPI_API_TOKEN repository secret.
6
+
7
+ on:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: Check that the tag matches the package version
20
+ run: |
21
+ tag="${GITHUB_REF_NAME#v}"
22
+ version="$(uv run --no-project python -c 'import tomllib,sys; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
23
+ test "$tag" = "$version" || { echo "tag v$tag != pyproject version $version"; exit 1; }
24
+ grep -q "__version__ = \"$version\"" screws/_version.py || { echo "_version.py disagrees"; exit 1; }
25
+ - run: uv sync --all-extras
26
+ - run: uv run pytest -q
27
+ - run: uv build
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ publish:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ permissions:
38
+ id-token: write # trusted publishing
39
+ contents: read
40
+ steps:
41
+ - uses: actions/download-artifact@v4
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+ - uses: pypa/gh-action-pypi-publish@release/v1
46
+ with:
47
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ .superpowers/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ *.egg-info/
screws-0.1.0/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rico Picone
4
+
5
+ Portions of this software are derived from the Modern Robotics code library
6
+ (https://github.com/NxRLab/ModernRobotics),
7
+ Copyright (c) 2018 Huan Weng, Bill Hunt, Jarvis Schultz, Mikhail Todes,
8
+ distributed under the same MIT License reproduced below.
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
screws-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.5
2
+ Name: screws
3
+ Version: 0.1.0
4
+ Summary: Screw-theory robotics: the Modern Robotics library, reorganised, with a CoppeliaSim bridge
5
+ Author: Rico Picone
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Rico Picone
9
+
10
+ Portions of this software are derived from the Modern Robotics code library
11
+ (https://github.com/NxRLab/ModernRobotics),
12
+ Copyright (c) 2018 Huan Weng, Bill Hunt, Jarvis Schultz, Mikhail Todes,
13
+ distributed under the same MIT License reproduced below.
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Requires-Python: >=3.10
34
+ Requires-Dist: numpy>=1.24
35
+ Provides-Extra: coppelia
36
+ Requires-Dist: coppeliasim-zmqremoteapi-client==2.0.4; extra == 'coppelia'
37
+ Provides-Extra: plot
38
+ Requires-Dist: matplotlib>=3.7; extra == 'plot'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # screws
42
+
43
+ Screw-theory robotics in Python, after Lynch and Park, *Modern Robotics* (MR).
44
+ `screws` is the MR code library reorganised: the same mathematics under
45
+ snake_case names, MR's own names kept as aliases, a `Robot` class, a URDF
46
+ loader, robots that ship ready to use, and a bridge to CoppeliaSim.
47
+
48
+ ## Install
49
+
50
+ ```
51
+ uv add screws # the mathematics: numpy is the only dependency
52
+ uv add "screws[coppelia]" # plus the CoppeliaSim ZMQ remote API client
53
+ ```
54
+
55
+ ## Four lines
56
+
57
+ ```python
58
+ import screws as sc
59
+
60
+ ur5 = sc.robots.ur5() # M, screw axes, inertias from the textbook's URDF
61
+ T = ur5.fk([0.3, -1.2, 0.8, -0.4, 1.1, 0.2]) # a reachable, non-singular pose
62
+ result = ur5.ik(T, theta0=[0.1, -1.4, 0.1, 0.1, 1.4, 0.1]) # result.theta, result.converged, result.history
63
+ ```
64
+
65
+ The free functions are the reference implementation and read as the book does:
66
+ `sc.exp6(sc.vec_to_se3(S * theta))` is $e^{[\mathcal{S}]\theta}$, `sc.fk_space(M, S, theta)`
67
+ is the space form of the product of exponentials, and `sc.FKinSpace` is the very same
68
+ function under MR's name.
69
+
70
+ Screw-axis lists are **6xn, one axis per column**, as MR writes them. Nothing guesses the
71
+ orientation (a 6x6 array is ambiguous for a six-axis arm), so hand entry goes through a
72
+ sequence of 6-vectors: `sc.Robot.from_screw_axes(M, [S1, S2, ...])`.
73
+
74
+ ## Check your own code against the library
75
+
76
+ ```python
77
+ import numpy as np
78
+ import screws as sc
79
+
80
+ def my_exp6(se3mat): ... # your implementation
81
+
82
+ rng = np.random.default_rng(0)
83
+ cases = [sc.vec_to_se3(rng.normal(size=6)) for _ in range(20)]
84
+ sc.testing.check(my_exp6, sc.exp6, cases) # raises on the first disagreement
85
+ ```
86
+
87
+ ## CoppeliaSim
88
+
89
+ Works with CoppeliaSim 4.9 or later through the ZMQ remote API (0.1 verified on 4.10.0).
90
+
91
+ ```python
92
+ from screws.coppelia import Scene
93
+
94
+ with Scene() as scene: # connects to localhost:23000 in stepping mode
95
+ arm = scene.arm("/UR5") # the joints under that tree, base to tip
96
+ robot = arm.robot() # a screws.Robot read off the scene at zero
97
+ arm.mode("position")
98
+ log = scene.run(lambda t, theta, dtheta: theta_desired(t), duration=5.0, arm=arm)
99
+ log.plot()
100
+ ```
101
+
102
+ `Scene` owns the connection and the clock (`start`, `step`, `stop`, `time`, `dt`,
103
+ `frame`, `show_frame`). `Arm` reads (`theta`, `dtheta`, `tau`, `tip_frame`) and commands
104
+ in one of three modes (`position`, `velocity`, `torque`), or `teleport`s without physics
105
+ to animate an IK history. `Arm.robot()` derives M and the screw axes from the scene's
106
+ joint frames (omega is the joint's z axis, v = -omega x q). Scene inertias arrive in 0.2.
107
+
108
+ ## Two UR5s
109
+
110
+ `sc.robots.ur5()` is built from the URDF the textbook prints in section 4.2: the
111
+ manufacturer's lengths and the printed inertias, the closest published match to the
112
+ simulator's model. `sc.robots.ur5(source="textbook")` is the rounded table of MR Figure
113
+ 4.6 with no inertias. They differ in the third decimal of the $v$ entries, and the course
114
+ notes' problems ask why.
115
+
116
+ ## Names
117
+
118
+ | Modern Robotics | `screws` |
119
+ |---|---|
120
+ | `NearZero` | `near_zero` |
121
+ | `Normalize` | `normalize` |
122
+ | `RotInv` | `rot_inv` |
123
+ | `VecToso3` | `vec_to_so3` |
124
+ | `so3ToVec` | `so3_to_vec` |
125
+ | `AxisAng3` | `axis_angle3` |
126
+ | `MatrixExp3` | `exp3` |
127
+ | `MatrixLog3` | `log3` |
128
+ | `RpToTrans` | `rp_to_transform` |
129
+ | `TransToRp` | `transform_to_rp` |
130
+ | `TransInv` | `transform_inv` |
131
+ | `VecTose3` | `vec_to_se3` |
132
+ | `se3ToVec` | `se3_to_vec` |
133
+ | `Adjoint` | `adjoint` |
134
+ | `ScrewToAxis` | `screw_axis` |
135
+ | `AxisAng6` | `axis_angle6` |
136
+ | `MatrixExp6` | `exp6` |
137
+ | `MatrixLog6` | `log6` |
138
+ | `ProjectToSO3` | `project_so3` |
139
+ | `ProjectToSE3` | `project_se3` |
140
+ | `DistanceToSO3` | `distance_so3` |
141
+ | `DistanceToSE3` | `distance_se3` |
142
+ | `TestIfSO3` | `is_so3` |
143
+ | `TestIfSE3` | `is_se3` |
144
+ | `FKinBody` | `fk_body` |
145
+ | `FKinSpace` | `fk_space` |
146
+ | `JacobianBody` | `jacobian_body` |
147
+ | `JacobianSpace` | `jacobian_space` |
148
+ | `IKinBody` | `ik_body` |
149
+ | `IKinSpace` | `ik_space` |
150
+
151
+ Where a screws function's signature matches MR's, the alias **is** that function
152
+ (`sc.FKinSpace is sc.fk_space`). `IKinBody` and `IKinSpace` are thin wrappers that return
153
+ MR's `(thetalist, success)` tuple; the primaries return an `IKResult` with the iteration
154
+ history. No deprecation warnings, ever.
155
+
156
+ ## Licence
157
+
158
+ MIT. Portions derived from the Modern Robotics code library, copyright 2018 Huan Weng,
159
+ Bill Hunt, Jarvis Schultz and Mikhail Todes, MIT licence; see `LICENSE`.
screws-0.1.0/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # screws
2
+
3
+ Screw-theory robotics in Python, after Lynch and Park, *Modern Robotics* (MR).
4
+ `screws` is the MR code library reorganised: the same mathematics under
5
+ snake_case names, MR's own names kept as aliases, a `Robot` class, a URDF
6
+ loader, robots that ship ready to use, and a bridge to CoppeliaSim.
7
+
8
+ ## Install
9
+
10
+ ```
11
+ uv add screws # the mathematics: numpy is the only dependency
12
+ uv add "screws[coppelia]" # plus the CoppeliaSim ZMQ remote API client
13
+ ```
14
+
15
+ ## Four lines
16
+
17
+ ```python
18
+ import screws as sc
19
+
20
+ ur5 = sc.robots.ur5() # M, screw axes, inertias from the textbook's URDF
21
+ T = ur5.fk([0.3, -1.2, 0.8, -0.4, 1.1, 0.2]) # a reachable, non-singular pose
22
+ result = ur5.ik(T, theta0=[0.1, -1.4, 0.1, 0.1, 1.4, 0.1]) # result.theta, result.converged, result.history
23
+ ```
24
+
25
+ The free functions are the reference implementation and read as the book does:
26
+ `sc.exp6(sc.vec_to_se3(S * theta))` is $e^{[\mathcal{S}]\theta}$, `sc.fk_space(M, S, theta)`
27
+ is the space form of the product of exponentials, and `sc.FKinSpace` is the very same
28
+ function under MR's name.
29
+
30
+ Screw-axis lists are **6xn, one axis per column**, as MR writes them. Nothing guesses the
31
+ orientation (a 6x6 array is ambiguous for a six-axis arm), so hand entry goes through a
32
+ sequence of 6-vectors: `sc.Robot.from_screw_axes(M, [S1, S2, ...])`.
33
+
34
+ ## Check your own code against the library
35
+
36
+ ```python
37
+ import numpy as np
38
+ import screws as sc
39
+
40
+ def my_exp6(se3mat): ... # your implementation
41
+
42
+ rng = np.random.default_rng(0)
43
+ cases = [sc.vec_to_se3(rng.normal(size=6)) for _ in range(20)]
44
+ sc.testing.check(my_exp6, sc.exp6, cases) # raises on the first disagreement
45
+ ```
46
+
47
+ ## CoppeliaSim
48
+
49
+ Works with CoppeliaSim 4.9 or later through the ZMQ remote API (0.1 verified on 4.10.0).
50
+
51
+ ```python
52
+ from screws.coppelia import Scene
53
+
54
+ with Scene() as scene: # connects to localhost:23000 in stepping mode
55
+ arm = scene.arm("/UR5") # the joints under that tree, base to tip
56
+ robot = arm.robot() # a screws.Robot read off the scene at zero
57
+ arm.mode("position")
58
+ log = scene.run(lambda t, theta, dtheta: theta_desired(t), duration=5.0, arm=arm)
59
+ log.plot()
60
+ ```
61
+
62
+ `Scene` owns the connection and the clock (`start`, `step`, `stop`, `time`, `dt`,
63
+ `frame`, `show_frame`). `Arm` reads (`theta`, `dtheta`, `tau`, `tip_frame`) and commands
64
+ in one of three modes (`position`, `velocity`, `torque`), or `teleport`s without physics
65
+ to animate an IK history. `Arm.robot()` derives M and the screw axes from the scene's
66
+ joint frames (omega is the joint's z axis, v = -omega x q). Scene inertias arrive in 0.2.
67
+
68
+ ## Two UR5s
69
+
70
+ `sc.robots.ur5()` is built from the URDF the textbook prints in section 4.2: the
71
+ manufacturer's lengths and the printed inertias, the closest published match to the
72
+ simulator's model. `sc.robots.ur5(source="textbook")` is the rounded table of MR Figure
73
+ 4.6 with no inertias. They differ in the third decimal of the $v$ entries, and the course
74
+ notes' problems ask why.
75
+
76
+ ## Names
77
+
78
+ | Modern Robotics | `screws` |
79
+ |---|---|
80
+ | `NearZero` | `near_zero` |
81
+ | `Normalize` | `normalize` |
82
+ | `RotInv` | `rot_inv` |
83
+ | `VecToso3` | `vec_to_so3` |
84
+ | `so3ToVec` | `so3_to_vec` |
85
+ | `AxisAng3` | `axis_angle3` |
86
+ | `MatrixExp3` | `exp3` |
87
+ | `MatrixLog3` | `log3` |
88
+ | `RpToTrans` | `rp_to_transform` |
89
+ | `TransToRp` | `transform_to_rp` |
90
+ | `TransInv` | `transform_inv` |
91
+ | `VecTose3` | `vec_to_se3` |
92
+ | `se3ToVec` | `se3_to_vec` |
93
+ | `Adjoint` | `adjoint` |
94
+ | `ScrewToAxis` | `screw_axis` |
95
+ | `AxisAng6` | `axis_angle6` |
96
+ | `MatrixExp6` | `exp6` |
97
+ | `MatrixLog6` | `log6` |
98
+ | `ProjectToSO3` | `project_so3` |
99
+ | `ProjectToSE3` | `project_se3` |
100
+ | `DistanceToSO3` | `distance_so3` |
101
+ | `DistanceToSE3` | `distance_se3` |
102
+ | `TestIfSO3` | `is_so3` |
103
+ | `TestIfSE3` | `is_se3` |
104
+ | `FKinBody` | `fk_body` |
105
+ | `FKinSpace` | `fk_space` |
106
+ | `JacobianBody` | `jacobian_body` |
107
+ | `JacobianSpace` | `jacobian_space` |
108
+ | `IKinBody` | `ik_body` |
109
+ | `IKinSpace` | `ik_space` |
110
+
111
+ Where a screws function's signature matches MR's, the alias **is** that function
112
+ (`sc.FKinSpace is sc.fk_space`). `IKinBody` and `IKinSpace` are thin wrappers that return
113
+ MR's `(thetalist, success)` tuple; the primaries return an `IKResult` with the iteration
114
+ history. No deprecation warnings, ever.
115
+
116
+ ## Licence
117
+
118
+ MIT. Portions derived from the Modern Robotics code library, copyright 2018 Huan Weng,
119
+ Bill Hunt, Jarvis Schultz and Mikhail Todes, MIT licence; see `LICENSE`.
@@ -0,0 +1,21 @@
1
+ # Releasing screws
2
+
3
+ 1. `uv run ruff check .` and `uv run pytest -q` are clean.
4
+ 2. With CoppeliaSim open (4.9 or later; 0.1 was verified on 4.10.0) and the stock UR5 model
5
+ loaded at `/UR5` (Model browser > robots > non-mobile > UR5.ttm; no tip dummy needed),
6
+ run the simulator tests: `SCREWS_COPPELIASIM=1 uv run pytest -q -m coppelia`.
7
+ If a run is killed mid-way the simulation can be left waiting on a dead stepping
8
+ client; `Scene.start()` stops such a simulation first, so a rerun recovers by itself.
9
+ 3. Bump the version in `screws/_version.py` and `pyproject.toml` (they must agree;
10
+ `tests/test_readme_table.py` pins the release number).
11
+ 4. If `screws/aliases.py` changed, regenerate the README table with
12
+ `uv run python tools/print_alias_table.py` and paste it under "Names".
13
+ 5. If the `modern_robotics` dev pin changed, re-harvest the docstring examples:
14
+ `uv run python tools/harvest_mr_examples.py`.
15
+ 6. Commit, then tag and push the tag: `git tag v<version> && git push origin v<version>`.
16
+ The `Publish to PyPI` workflow (`.github/workflows/publish.yml`) checks that the tag
17
+ matches `pyproject.toml` and `_version.py`, runs the suite, builds, and uploads.
18
+ It uses the `PYPI_API_TOKEN` repository secret when set, else PyPI trusted publishing
19
+ (register the workflow as a publisher for `screws` on pypi.org, environment `pypi`).
20
+ Fallback by hand: `uv build && uvx twine upload dist/*` (`uv publish` ignores `~/.pypirc`).
21
+ 7. Watch it: `gh run watch`. Then pin the new version in the course repo.