tuyamock 0.0.1__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.
tuyamock-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tuyamock contributors
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.
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.4
2
+ Name: tuyamock
3
+ Version: 0.0.1
4
+ Summary: Protocol-faithful mock of a Tuya local-protocol device, built on tinytuya primitives
5
+ Author: tuyamock contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/3735943886/tuyamock
8
+ Project-URL: Repository, https://github.com/3735943886/tuyamock
9
+ Project-URL: Issues, https://github.com/3735943886/tuyamock/issues
10
+ Keywords: tuya,tinytuya,mock,testing,iot,smart-home,local-protocol
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Testing :: Mocking
22
+ Classifier: Topic :: Home Automation
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: tinytuya>=1.13
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7.0; extra == "test"
29
+ Dynamic: license-file
30
+
31
+ # tuyamock
32
+
33
+ [![CI](https://github.com/3735943886/tuyamock/actions/workflows/ci.yml/badge.svg)](https://github.com/3735943886/tuyamock/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/tuyamock.svg)](https://pypi.org/project/tuyamock/)
35
+ [![Python versions](https://img.shields.io/pypi/pyversions/tuyamock.svg)](https://pypi.org/project/tuyamock/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
+
38
+ A protocol-faithful **mock of a Tuya local-protocol device**, built on
39
+ [tinytuya](https://github.com/jasonacox/tinytuya)'s own message/crypto
40
+ primitives.
41
+
42
+ It plays the *device* side of the Tuya LAN protocol so you can test Tuya
43
+ *clients* end-to-end without real hardware. Because the device side is
44
+ implemented with tinytuya's `pack_message` / `unpack_message` / `AESCipher`, the
45
+ mock is an **independent oracle**: a client that re-implements the protocol (in
46
+ any language) can be validated against it without the validation being circular.
47
+
48
+ ## Why "independent oracle"
49
+
50
+ The intended workflow is a two-step bootstrap:
51
+
52
+ 1. **mock ⟷ tinytuya client** — prove the mock is protocol-correct using
53
+ tinytuya as the reference client. Once these tests pass, the mock is treated
54
+ as ground truth. *(This is exactly what `tests/test_with_tinytuya.py` does.)*
55
+ 2. **mock ⟷ client-under-test** — point the other client at the validated mock.
56
+ Any failure is then isolated to that client, not the harness.
57
+
58
+ If the mock spoke a re-implemented dialect of the protocol, step 1 would be
59
+ circular and bugs could hide on both sides. Reusing tinytuya's primitives avoids
60
+ that.
61
+
62
+ ## Dependence on tinytuya (read this before upgrading tinytuya)
63
+
64
+ The mock does **not** re-implement the Tuya protocol — it **imports tinytuya at
65
+ runtime and calls tinytuya's own functions** for all crypto/framing. This is
66
+ deliberate (it's what makes the mock an independent oracle: the device and the
67
+ tinytuya reference client run the *same* byte-level code, so they cannot silently
68
+ disagree). But it means the mock's correctness is **coupled to the installed
69
+ tinytuya** at three different layers, only one of which auto-adapts. The failure
70
+ modes are different per layer — know which is which before bumping the dependency.
71
+
72
+ ### Layer 1 — imported symbols (the linkage surface)
73
+
74
+ Pulled straight from tinytuya and *called*, never copied. All from **internal**
75
+ modules (`tinytuya.core.*`), not a documented/stable public API:
76
+
77
+ | imported symbol | import path | used in | role |
78
+ |-----------------|-------------|---------|------|
79
+ | `pack_message` | `tinytuya.core.message_helper` | `protocol.py` (`VersionProfile.pack_response`), `server.py` (discovery beacon) | assemble 55AA / 6699 frames |
80
+ | `unpack_message` | `tinytuya.core.message_helper` | `protocol.py` (`VersionProfile.unpack_request`) | disassemble one frame → `TuyaMessage` |
81
+ | `TuyaMessage` | `tinytuya.core.message_helper` | `protocol.py` (`pack_response`), `server.py` (`_maybe_broadcast`) | 8-field namedtuple passed to `pack_message` |
82
+ | `parse_header` | `tinytuya.core.message_helper` | `device.py` (`take_frames`) | read `total_length` for stream framing |
83
+ | `AESCipher` | `tinytuya.core.crypto_helper` | `protocol.py` (the crypto-utility wrappers) | AES-ECB / AES-GCM + PKCS#7 padding |
84
+ | `command_types` (`CT`) | `tinytuya.core` | protocol/device/server | command opcode *values* |
85
+ | `header` (`H`) | `tinytuya.core` | protocol/server | prefix/header/version-byte constants |
86
+ | `DecodeError` | `tinytuya.core.exceptions` | device/server | raised on short/garbled frames |
87
+ | `tinytuya.udpkey` | `tinytuya` (top level) | `server.py` (`_maybe_broadcast`) | HMAC key for the UDP discovery beacon |
88
+
89
+ Constants we read out of `H`: `PREFIX_55AA_VALUE`, `PREFIX_6699_VALUE`,
90
+ `PROTOCOL_3x_HEADER`, `PROTOCOL_VERSION_BYTES_31`. Opcodes we read out of `CT`:
91
+ `SESS_KEY_NEG_START/RESP/FINISH`, `DP_QUERY`, `DP_QUERY_NEW`, `CONTROL`,
92
+ `CONTROL_NEW`, `UPDATEDPS`, `HEART_BEAT`, `UDP_NEW`. If tinytuya renames/moves any
93
+ of these, the mock fails to **import** — loud, immediate, trivial to diagnose.
94
+
95
+ ### Layer 2 — behavioral contracts (the dangerous, implicit surface)
96
+
97
+ Beyond mere symbol existence, the mock hard-codes assumptions about *how these
98
+ functions behave and what their arguments mean*. These are not enforced by any
99
+ type and will not raise on a signature change — they produce **wrong bytes**.
100
+ Enumerated so a reviewer knows exactly what to re-verify against tinytuya source:
101
+
102
+ * **`TuyaMessage` field order/semantics.** We construct it positionally as
103
+ `TuyaMessage(seqno, cmd, retcode, payload, crc, crc_good, prefix, iv)` in
104
+ `protocol.py` `pack_response` (and `server.py` `_maybe_broadcast`). The last field
105
+ doubles as the GCM-iv/flag: `True` for 6699, `None` for 55AA. If tinytuya
106
+ reorders or repurposes any field, frames serialize wrong with no error.
107
+ * **`pack_message(msg, hmac_key=…)`** — we rely on `hmac_key=None` ⇒ CRC32 framing
108
+ and `hmac_key=<bytes>` ⇒ HMAC-SHA256 framing (`VersionProfile.framing_key()`
109
+ returns the session key on v3.4/v3.5, `None` on v3.1–3.3). And that `pack_message`
110
+ reads the prefix from `msg.prefix` to pick 55AA vs 6699.
111
+ * **`unpack_message(frame, hmac_key=…, no_retcode=True)`** — we pass
112
+ `no_retcode=True` on requests (client→device frames have no retcode) and instead
113
+ prepend the 4-byte retcode **ourselves** on 55AA responses via the `RETCODE`
114
+ constant (in `pack_response`). The retcode convention living *outside*
115
+ `pack_message` for 55AA but *inside* it for 6699 is a tinytuya-specific asymmetry
116
+ we mirror by hand.
117
+ * **`AESCipher` keyword contract.** We depend on the exact kwargs
118
+ `encrypt(data, use_base64=, pad=, iv=)` and
119
+ `decrypt(data, use_base64=, decode_text=)` (see the crypto-utility wrappers at the
120
+ top of `protocol.py`), e.g. `use_base64=False` for raw-bytes ECB on the wire,
121
+ `pad=False` for session-key derivation, and `iv=` selecting GCM mode and being
122
+ prepended to the ciphertext. The v3.5 session key is specifically
123
+ `GCM(nonce, iv=client_nonce[:12])[12:28]` (`derive_session_key_gcm`) — a 16-byte
124
+ slice out of the GCM output whose offset is a tinytuya implementation detail.
125
+ * **`parse_header(...).total_length`** is how `take_frames` (in `device.py`)
126
+ re-frames the TCP byte stream (multiple frames per read / frame split across
127
+ reads). A change to that attribute name or its length accounting silently
128
+ corrupts framing.
129
+ * **`NO_PROTOCOL_HEADER_CMDS`** — `protocol.NEGOTIATION_CMDS` is a hand-copied
130
+ mirror of `XenonDevice.NO_PROTOCOL_HEADER_CMDS`. If tinytuya adds a command that
131
+ skips the version header, our copy goes stale.
132
+
133
+ ### Layer 3 — client protocol *policy* (hand-mirrored, never auto-adapts)
134
+
135
+ tinytuya exposes primitives but **not** "how a device is supposed to respond." All
136
+ of that is replicated by hand from `XenonDevice` into our `VersionProfile`s (in
137
+ `protocol.py`) and command dispatch (`Session` in `device.py`). If tinytuya changes
138
+ *client* behaviour (new handshake, moved header, a v3.6), the mock **will not
139
+ notice**:
140
+
141
+ * **version-header placement** — `version_bytes + PROTOCOL_3x_HEADER` is prepended
142
+ *after* ECB on v3.2/3.3 (plaintext on the wire, stripped before decrypt — base
143
+ `VersionProfile._decrypt_payload`/`encrypt_payload`), encrypted *with* the payload
144
+ on v3.4 (`V34Profile`), and carried *inside* the GCM payload on v3.5 (`V35Profile`).
145
+ Real-device data-plane responses must include it or the client's `device22`
146
+ `len & 0x0F` strip heuristic chops the JSON.
147
+ * **device22 dialect** — reject the standard query with `json obj data unvalid` (the
148
+ `DATA_UNVALID` constant, used in `Session.handle`) so the client detects device22
149
+ and retries via `CONTROL_NEW`; v3.2 is *always* device22; device22 returns only the
150
+ requested dps. Only valid on v3.2–3.4 (rejected at config in `DeviceConfig` for
151
+ 3.1/3.5).
152
+ * **standard query opcode by version** — `DP_QUERY` (v3.1–3.3) vs `DP_QUERY_NEW`
153
+ (v3.4+); `--dev22` must reject *whichever* applies (keying only on `DP_QUERY`
154
+ silently no-op'd device22 on v3.4). See the `DP_QUERY`/`DP_QUERY_NEW` branch in
155
+ `Session.handle`.
156
+ * **session-key handshake** — START→RESP(nonce+HMAC)→FINISH(verify HMAC, install
157
+ key), key swapped only *after* FINISH; v3.4 key = `ECB(session_nonce)`, v3.5 key =
158
+ the GCM slice above (`Session._handle_neg_start`/`_handle_neg_finish` plus each
159
+ profile's `derive_session_key`).
160
+ * **v3.1 payload scheme** — `b"3.1" + md5hex[8:24] + base64(ECB(data))`
161
+ (`encrypt_31`/`decrypt_31` in `protocol.py`).
162
+ * **device→client seqno** is a device-side incrementing counter, not a request echo
163
+ (`Session.__init__` / `pack_response`).
164
+
165
+ ### Summary: does the mock auto-adapt to a tinytuya algorithm change?
166
+
167
+ | change in tinytuya | mock reaction |
168
+ |--------------------|---------------|
169
+ | internals of `pack_message`/`unpack_message`/`AESCipher`/HMAC/GCM | **auto-adapts** (same code path runs on both ends) |
170
+ | value of an opcode or magic constant in `CT`/`H` | **auto-adapts** (we read the symbol, not a literal) |
171
+ | rename/move of an imported internal symbol | **breaks loudly** at import (Layer 1) |
172
+ | `TuyaMessage` field order, kwarg contract, `total_length` semantics | **breaks silently → wrong bytes** (Layer 2) |
173
+ | client *policy*: header timing, device22 flow, handshake, new version | **does not adapt** — must hand-edit `protocol.py`/`device.py` (Layer 3) |
174
+
175
+ So: low-level crypto/framing is *delegated* and tracks tinytuya for free; the
176
+ per-version protocol *policy* is *replicated* and must be maintained by hand. Layer 2
177
+ is the trap — it neither auto-adapts nor fails loudly.
178
+
179
+ > Every Layer-1/2/3 site above is tagged in the source with a `TINYTUYA-COUPLING`
180
+ > comment (noting its layer). `grep -rn TINYTUYA-COUPLING src/` enumerates exactly
181
+ > what to re-verify against tinytuya on an upgrade.
182
+
183
+ ### Pinned version & upgrade procedure
184
+
185
+ The dev `.venv` pins tinytuya via an editable install (`pip install -e ../tinytuya`,
186
+ currently **tinytuya 1.18.1**). Treat any tinytuya bump as a deliberate event, not
187
+ a transparent one:
188
+
189
+ 1. Re-run `pytest tests/test_with_tinytuya.py` — the stage-1 bootstrap is the **only**
190
+ thing that catches Layer-2/Layer-3 silent drift. It exercises every version + the
191
+ device22 quirk against the real tinytuya client.
192
+ 2. If it fails, diff `tinytuya/core/message_helper.py` and `crypto_helper.py` against
193
+ the Layer-2 contracts above, then re-check the `XenonDevice` policy points (Layer 3).
194
+ 3. Only then re-point your client-under-test (stage 2) at the mock.
195
+
196
+ ## Install
197
+
198
+ ```bash
199
+ pip install tuyamock # pulls tinytuya from PyPI
200
+ ```
201
+
202
+ For development (run the test suite, editable install):
203
+
204
+ ```bash
205
+ python -m venv .venv && . .venv/bin/activate
206
+ pip install -e ".[test]"
207
+ ```
208
+
209
+ ## Usage
210
+
211
+ ```bash
212
+ # v3.5 bulb on the default port (6668)
213
+ python -m tuyamock --version 3.5 --local-key thisisarealkey00
214
+
215
+ # OS-assigned port (printed as the first stdout line) for parallel tests
216
+ python -m tuyamock --port 0 --version 3.4 --local-key thisisarealkey00
217
+
218
+ # inject data points; emulate the device22 status quirk (v3.3/v3.4)
219
+ python -m tuyamock --version 3.3 --dev22 --dps '{"1": true, "20": "white"}'
220
+ ```
221
+
222
+ The first line on **stdout** is always the bound TCP port (handy with
223
+ `--port 0`); all logging goes to **stderr** (`-v` = info, `-vv` = debug).
224
+
225
+ ### Key options
226
+
227
+ | flag | meaning |
228
+ |------|---------|
229
+ | `--version {3.1,3.2,3.3,3.4,3.5}` | protocol version to emulate |
230
+ | `--local-key` | 16-byte device key |
231
+ | `--port` | TCP port (`0` = OS-assigned, printed to stdout) |
232
+ | `--dps` | canned data points as a JSON object |
233
+ | `--dev22` | emulate device22 (only valid on v3.2–v3.4; see below) |
234
+ | `--discovery` | periodically emit the UDP discovery beacon |
235
+ | `--max-connections N` | exit cleanly after N client connections (test isolation) |
236
+
237
+ ## In-process (Python API)
238
+
239
+ For tests you can run the mock in a background thread and drive it with tinytuya
240
+ from a single file — no subprocess, no port wrangling:
241
+
242
+ ```python
243
+ import tinytuya
244
+ import tuyamock
245
+
246
+ with tuyamock.MockDevice(local_key="thisisarealkey00", version="3.5",
247
+ dps={"1": True, "20": "white"}) as mock:
248
+ d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
249
+ "thisisarealkey00", version=3.5, port=mock.port)
250
+ print(d.status()["dps"]) # {'1': True, '20': 'white'}
251
+ d.set_value("20", "red")
252
+ print(mock.dps) # {'1': True, '20': 'red'} (live device state)
253
+ ```
254
+
255
+ `MockDevice(...)` takes the same options as the CLI (`version`, `dps`, `dev22`,
256
+ `port=0` for an OS-assigned port, …). `mock.port` is the bound port, `mock.dps`
257
+ is the live device state, and `mock.server.connections` counts accepted
258
+ connections. See [`examples/inprocess_demo.py`](examples/inprocess_demo.py).
259
+
260
+ The device22 reject→reconnect handshake works against the mock: a single
261
+ `status()` on a device22 device uses two connections (the mock rejects the
262
+ standard query, tinytuya detects device22, reconnects, and retries via
263
+ `CONTROL_NEW`).
264
+
265
+ ## Stateful device
266
+
267
+ The mock keeps live dps state and responds to the full tinytuya client command
268
+ surface, so a `set` is reflected by a later `status()`:
269
+
270
+ | tinytuya client call | wire command | mock behaviour |
271
+ |----------------------|--------------|----------------|
272
+ | `status()` | `DP_QUERY` / `DP_QUERY_NEW` | returns current dps |
273
+ | `set_value()` / `set_status()` / `turn_on()` / `turn_off()` | `CONTROL` (`CONTROL_NEW` on v3.4+) | merges dps into state, reports the changed dps |
274
+ | `set_multiple_values()` | `CONTROL` | merges all, reports changed |
275
+ | `updatedps()` | `UPDATEDPS` | reports the requested dpIds |
276
+ | `heartbeat()` | `HEART_BEAT` | empty-payload ack |
277
+
278
+ State is shared across connections (tinytuya opens a fresh connection per
279
+ command by default), so set-then-query works end-to-end.
280
+
281
+ ```python
282
+ import tinytuya
283
+ d = tinytuya.Device("eb0123456789abcdefghij", "127.0.0.1",
284
+ "thisisarealkey00", version=3.5, port=PORT)
285
+ d.set_value("1", True) # -> {"dps": {"1": True}}
286
+ d.status()["dps"]["1"] # -> True (persisted)
287
+ ```
288
+
289
+ ## Supported protocol versions
290
+
291
+ | version | framing | payload crypto | session key |
292
+ |---------|---------|----------------|-------------|
293
+ | 3.1 | 55AA + CRC32 | `3.1`+md5+base64(AES-ECB) | — |
294
+ | 3.2 | 55AA + CRC32 | AES-ECB(local_key) | — *(client uses device22 dialect)* |
295
+ | 3.3 | 55AA + CRC32 | AES-ECB(local_key) | — |
296
+ | 3.4 | 55AA + HMAC-SHA256 | AES-ECB(session_key) | ECB-derived |
297
+ | 3.5 | 6699 + AES-GCM | (GCM is the framing) | GCM-derived |
298
+
299
+ ### device22
300
+
301
+ A `device22` device returns only the data points it is explicitly asked for and
302
+ rejects the standard status query with `json obj data unvalid`, forcing the
303
+ client to retry via `CONTROL_NEW`. Enable it with `--dev22`.
304
+
305
+ It is only meaningful where the tinytuya reference client supports it:
306
+
307
+ | version | device22 |
308
+ |---------|----------|
309
+ | 3.1 | not supported — `--dev22` is **rejected at startup** |
310
+ | 3.2 | **always** device22 (the v3.2 client forces the dialect; `--dev22` optional) |
311
+ | 3.3 | opt-in via `--dev22` (client auto-detects from the rejected query) |
312
+ | 3.4 | opt-in via `--dev22` (rejects `DP_QUERY_NEW`, not `DP_QUERY`) |
313
+ | 3.5 | not supported — `--dev22` is **rejected at startup** |
314
+
315
+ The standard status query differs by version (`DP_QUERY` on v3.1–3.3,
316
+ `DP_QUERY_NEW` on v3.4+), and `--dev22` rejects whichever one applies.
317
+
318
+ ## Architecture
319
+
320
+ * **`protocol.py`** — per-version `VersionProfile`s plus shared crypto/framing
321
+ utilities. Each profile captures framing, the payload codec, and session
322
+ negotiation, so the rest of the code is version-agnostic.
323
+ * **`device.py`** — `DeviceConfig` (static config), `Session` (per-connection
324
+ nonces/keys), and command dispatch.
325
+ * **`server.py`** — single-client IPv4 TCP loop (avoids the original example's
326
+ `AF_INET6` dual-stack trap), `--port 0` support, clean SIGTERM/SIGINT
327
+ shutdown, optional UDP discovery beacon. Serving **one connection at a time is
328
+ protocol-faithful**, not a limitation: a real Tuya device handles a single local
329
+ TCP connection and does not support concurrent local connections, so clients talk
330
+ to it serially (a fresh connection per command). Adding multi-client support
331
+ would make the mock behave *unlike* real hardware — don't.
332
+ * **`cli.py`** — the `python -m tuyamock` entry point.
333
+
334
+ ## Tests
335
+
336
+ ```bash
337
+ pytest -v
338
+ ```
339
+
340
+ `tests/test_with_tinytuya.py` runs the stage-1 bootstrap across every supported
341
+ version plus the device22 quirk, custom-dps injection, `--port 0`, wrong-key
342
+ rejection, parallel isolation, and clean shutdown.
343
+ `tests/test_inprocess.py` adds the `MockDevice` API tests and a single-CPU-pinned
344
+ reconnect stress test.
345
+
346
+ CI (`.github/workflows/ci.yml`) runs the suite on Python 3.8–3.12 for every push
347
+ and pull request.
348
+
349
+ ## Releasing (PyPI)
350
+
351
+ Publishing is automated by `.github/workflows/publish.yml` via PyPI
352
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no API
353
+ token stored). To cut a release:
354
+
355
+ 1. Bump the version — single source: `__version__` in
356
+ `src/tuyamock/__init__.py` (pyproject reads it via `dynamic = ["version"]`) —
357
+ and commit.
358
+ 2. Tag and push, e.g. `git tag v0.0.1 && git push origin v0.0.1`.
359
+ 3. Create a GitHub Release for that tag. Publishing the release triggers the
360
+ workflow: it runs the tests, builds the sdist + wheel, `twine check`s them, and
361
+ uploads to PyPI.
362
+
363
+ One-time setup: on PyPI add a trusted publisher for the project (`tuyamock`)
364
+ pointing at this repo, `publish.yml`, environment `pypi`. (Prefer a token? Replace
365
+ the OIDC step with `with: password: ${{ secrets.PYPI_API_TOKEN }}`.)