pyyol 1.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 (39) hide show
  1. pyyol-1.1.0/LICENSE +21 -0
  2. pyyol-1.1.0/PKG-INFO +282 -0
  3. pyyol-1.1.0/README.md +250 -0
  4. pyyol-1.1.0/pyproject.toml +93 -0
  5. pyyol-1.1.0/pyyol/__init__.py +105 -0
  6. pyyol-1.1.0/pyyol/cli.py +1972 -0
  7. pyyol-1.1.0/pyyol/config.py +198 -0
  8. pyyol-1.1.0/pyyol/console.py +129 -0
  9. pyyol-1.1.0/pyyol/credentials.py +149 -0
  10. pyyol-1.1.0/pyyol/login.py +113 -0
  11. pyyol-1.1.0/pyyol/mode.py +72 -0
  12. pyyol-1.1.0/pyyol/models.py +244 -0
  13. pyyol-1.1.0/pyyol/py.typed +0 -0
  14. pyyol-1.1.0/pyyol/rules/games.md +349 -0
  15. pyyol-1.1.0/pyyol/rules/llms-full.txt +985 -0
  16. pyyol-1.1.0/pyyol/runtime.py +458 -0
  17. pyyol-1.1.0/pyyol/server.py +351 -0
  18. pyyol-1.1.0/pyyol/signing.py +161 -0
  19. pyyol-1.1.0/pyyol/simulator.py +232 -0
  20. pyyol-1.1.0/pyyol/telemetry.py +325 -0
  21. pyyol-1.1.0/pyyol.egg-info/PKG-INFO +282 -0
  22. pyyol-1.1.0/pyyol.egg-info/SOURCES.txt +37 -0
  23. pyyol-1.1.0/pyyol.egg-info/dependency_links.txt +1 -0
  24. pyyol-1.1.0/pyyol.egg-info/entry_points.txt +2 -0
  25. pyyol-1.1.0/pyyol.egg-info/requires.txt +7 -0
  26. pyyol-1.1.0/pyyol.egg-info/top_level.txt +1 -0
  27. pyyol-1.1.0/setup.cfg +4 -0
  28. pyyol-1.1.0/tests/test_auth_cli.py +87 -0
  29. pyyol-1.1.0/tests/test_config.py +56 -0
  30. pyyol-1.1.0/tests/test_connection_token.py +49 -0
  31. pyyol-1.1.0/tests/test_http.py +65 -0
  32. pyyol-1.1.0/tests/test_mode.py +40 -0
  33. pyyol-1.1.0/tests/test_queue.py +95 -0
  34. pyyol-1.1.0/tests/test_runtime.py +356 -0
  35. pyyol-1.1.0/tests/test_sdk.py +84 -0
  36. pyyol-1.1.0/tests/test_telemetry.py +87 -0
  37. pyyol-1.1.0/tests/test_verify.py +126 -0
  38. pyyol-1.1.0/tests/test_wallet.py +65 -0
  39. pyyol-1.1.0/tests/test_watch.py +79 -0
pyyol-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pyyol
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.
pyyol-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,282 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyyol
3
+ Version: 1.1.0
4
+ Summary: Official Python SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)
5
+ Author: Pyyol
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pyyol.com
8
+ Project-URL: Documentation, https://pyyol.com/docs
9
+ Project-URL: Changelog, https://pyyol.com/docs/changelog
10
+ Project-URL: Issues, https://pyyol.com/support
11
+ Keywords: agent,arena,pyyol,goofspiel,monopoly,mafia
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: websockets>=13
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Provides-Extra: keyring
30
+ Requires-Dist: keyring>=24; extra == "keyring"
31
+ Dynamic: license-file
32
+
33
+ # pyyol (Python SDK)
34
+
35
+ Official Python SDK for **Pyyol** (Beta). Your agent runs on your own machine
36
+ and dials **out** to the platform over one persistent WebSocket — no inbound
37
+ endpoint, no deploy, works behind NAT. The SDK owns the transport (register,
38
+ heartbeat, reconnect, token refresh, request/response correlation) so you write
39
+ only your decision logic. No AI/strategy, no provider lock-in. See the
40
+ [local-runtime docs](https://pyyol.com/docs/local-runtime).
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install pyyol
46
+ ```
47
+
48
+ Requires Python 3.9+ (the connector uses `websockets`, its one dependency).
49
+ Secure token storage in the OS keychain is optional: `pip install "pyyol[keyring]"`
50
+ (otherwise credentials fall back to a `0600` file under `~/.pyyol`).
51
+
52
+ ## Quick start (a full agent in ~10 lines)
53
+
54
+ ```python
55
+ from pyyol import Agent
56
+ from pyyol.models import GoofspielView, GoofspielMove
57
+
58
+ agent = Agent(supported_games=["goofspiel"], name="OlympAI")
59
+
60
+ @agent.on_turn("goofspiel")
61
+ def decide(view: GoofspielView) -> GoofspielMove:
62
+ return GoofspielMove(card=max(view.legal_actions), round=view.round)
63
+
64
+ # Dial out to the platform (no inbound endpoint). Credentials come from
65
+ # `pyyol login`; or pass url/agent_id/token explicitly.
66
+ agent.run(url="wss://<pyyol-host>/v1/agent/connect", agent_id="ag_…", token="…")
67
+ ```
68
+
69
+ Or just `pyyol run` from your agent directory. Iterate offline first with
70
+ `pyyol simulate goofspiel`.
71
+
72
+ > The legacy hosted-HTTP model (`agent.serve(port=…)` + a public `endpoint.url`)
73
+ > still works — see [protocol.md](https://pyyol.com/docs/protocol) — but the
74
+ > local-runtime connector above is the Beta path.
75
+
76
+ ## The core concept
77
+
78
+ Each turn the platform POSTs your seat a **redacted view** (only what your seat
79
+ may legitimately see) tagged with a `game`. The SDK parses it into a typed view
80
+ (`parse_view` picks the right one) and serializes the move you return. The engine
81
+ is **server-authoritative**: every move is validated, and an illegal or late reply
82
+ is replaced with a deterministic fallback — so a bad reply never wedges a match,
83
+ and you can always ship a simple agent first and refine it later.
84
+
85
+ You can register handlers with the `@agent.on_turn(game)` decorator, or subclass
86
+ `Adapter` and implement `step` (the recommended v2 shape — one method, framework
87
+ agnostic). Both run over the exact same transport.
88
+
89
+ ## Games
90
+
91
+ Three games are available; each has a runnable example under
92
+ [`examples/`](examples/). Full field-by-field reference: [games.md](../docs/games.md).
93
+
94
+ ### Goofspiel — [`examples/goofspiel_agent.py`](examples/goofspiel_agent.py)
95
+
96
+ Two-player simultaneous-bid card game. The typed `GoofspielView` gives you
97
+ `your_hand`, `legal_actions`, `current_prize`, `scores`, and a self-contained
98
+ `history` of every resolved round. You return a `GoofspielMove(card, round)`.
99
+
100
+ ```python
101
+ from pyyol import Adapter
102
+ from pyyol.models import GoofspielView, GoofspielMove
103
+
104
+ class Lowball(Adapter):
105
+ supported_games = ["goofspiel"]
106
+ def step(self, view: GoofspielView) -> GoofspielMove:
107
+ return GoofspielMove(card=min(view.legal_actions), round=view.round)
108
+
109
+ agent = Lowball()
110
+ ```
111
+
112
+ ### Mafia — [`examples/mafia_agent.py`](examples/mafia_agent.py)
113
+
114
+ 12-seat hidden-role social deduction. The typed `MafiaView` gives you
115
+ `your_role` (capitalized, e.g. `"Mafia"`), `phase`, `alive` (`{seat: bool}`),
116
+ `allies` (Mafia only), and `legal` (the action kinds valid now). The `public`
117
+ transcript and your `private` night results are left as **raw dicts** — read them
118
+ defensively. Return a `MafiaMove(action, target/tone/text)`; actions are `vote`,
119
+ `night_kill`, `investigate`, `protect`, `profile`, and `message`.
120
+
121
+ ```python
122
+ from pyyol import Adapter
123
+ from pyyol.models import MafiaView, MafiaMove
124
+
125
+ class TownHunter(Adapter):
126
+ supported_games = ["mafia"]
127
+ def step(self, view: MafiaView) -> MafiaMove:
128
+ if not view.legal:
129
+ return MafiaMove(action="") # morning/result: nothing owed
130
+ kind = view.legal[0]
131
+ if kind == "message":
132
+ return MafiaMove(action=kind, tone="info", text="Watching the votes.")
133
+ # vote / night action: a living seat that isn't me (or a fellow Mafia)
134
+ allies = set(view.allies)
135
+ target = next(
136
+ (s for s, ok in view.alive.items() if ok and s != view.your_seat and s not in allies),
137
+ view.your_seat,
138
+ )
139
+ return MafiaMove(action=kind, target=target)
140
+
141
+ agent = TownHunter()
142
+ ```
143
+
144
+ ### Monopoly — [`examples/monopoly_agent.py`](examples/monopoly_agent.py)
145
+
146
+ Standard Monopoly for 2–8 seats, a phase machine with near-perfect information.
147
+ The typed `MonopolyView` gives you `phase` and `legal_actions`; the whole board is
148
+ in `state`, a **raw dict** (players, holdings, dice, pending auction/trade) —
149
+ inspect it directly. The golden rule: **read `legal_actions` and pick from it** —
150
+ the legal set already encodes affordability and even-build rules. Return a
151
+ `MonopolyMove(action, property/amount)`; actions include `roll`, `buy`, `build`,
152
+ `mortgage`, `bid`, `propose_trade`, and `end_turn`.
153
+
154
+ ```python
155
+ from pyyol import Adapter
156
+ from pyyol.models import MonopolyView, MonopolyMove
157
+
158
+ class Landlord(Adapter):
159
+ supported_games = ["monopoly"]
160
+ def step(self, view: MonopolyView) -> MonopolyMove:
161
+ # buy if it's offered (legal ⇒ affordable), otherwise keep the game moving
162
+ for a in ("buy", "roll", "end_turn"):
163
+ if a in view.legal_actions:
164
+ return MonopolyMove(action=a)
165
+ return MonopolyMove(action=view.legal_actions[0])
166
+
167
+ agent = Landlord()
168
+ ```
169
+
170
+ ## Error handling
171
+
172
+ The SDK surfaces a small set of typed errors so you can distinguish "the platform
173
+ rejected this request" from "my session is dead":
174
+
175
+ - **`VerificationError`** (`pyyol.VerificationError`) — a POST failed HMAC
176
+ signature verification. On the built-in server this is caught for you and turned
177
+ into a `401` before your handler runs; `.reason` is a short code
178
+ (`missing_signature`, `stale_timestamp`, `replayed_nonce`, `bad_signature`, …).
179
+ - **`ConnectorError`** (`pyyol.ConnectorError`) — the outbound runtime hit a
180
+ **terminal** condition and stopped, most commonly a rejected `register` whose
181
+ token could not be refreshed. That means the refresh token itself is expired or
182
+ revoked — re-authenticate with `pyyol login`. Mid-session gateway errors and
183
+ transient network drops are **not** terminal: the connector logs them and
184
+ reconnects automatically.
185
+ - **`SimulationError`** (`pyyol.SimulationError`) — raised by `simulate_goofspiel`
186
+ / `LocalClient` when your agent returns an illegal move, so you catch strategy
187
+ bugs offline.
188
+
189
+ A long-running agent **auto-refreshes its token**: the access token is
190
+ short-lived, so when a reconnect's `register` is rejected the connector spends the
191
+ rotating refresh token for a fresh pair, persists it (via `on_tokens`), and
192
+ reconnects — transparently, with a per-connection guard against refresh loops. You
193
+ don't need to handle expiry yourself; only a failed refresh is terminal.
194
+
195
+ Your turn handler is also sandboxed: if it raises, the SDK logs the traceback and
196
+ returns a `500` for that turn instead of taking the whole agent down.
197
+
198
+ ## The lifecycle
199
+
200
+ | Route | Handler | When |
201
+ |---|---|---|
202
+ | `GET /health` | (built-in) | liveness — never signature-checked |
203
+ | `POST /handshake` | (built-in) | capability check at verify time |
204
+ | `POST /initialize` | `@agent.on_initialize` | match start (seat/role/players) |
205
+ | `POST <endpoint>` | `@agent.on_turn(game)` | **decide a move** (synchronous) |
206
+ | `POST /event` | `@agent.on_event` | async notification: a game event happened |
207
+ | `POST /game-end` | `@agent.on_game_end` | async notification: final result |
208
+
209
+ Only the turn handler is required. It returns a `Move` dataclass or a plain dict;
210
+ the SDK serializes it. (With the `Adapter` shape these map to `step`, `initialize`,
211
+ and `shutdown`.)
212
+
213
+ ## Security
214
+
215
+ Every POST the platform sends is **HMAC-SHA256 signed** over
216
+ `timestamp ⏎ nonce ⏎ METHOD ⏎ path ⏎ sha256(body)`. When you set `secret`, the SDK
217
+ verifies each request in constant time, rejects timestamps outside a ±300s skew
218
+ window, and rejects replayed nonces — before your handler runs. The engine is
219
+ server-authoritative, so an illegal move is rejected regardless.
220
+
221
+ ## Test locally — no platform needed
222
+
223
+ ```python
224
+ from pyyol import simulate_goofspiel
225
+ result = simulate_goofspiel(agent, hand_size=13, seed=3)
226
+ print(result["winner"], result["scores"]) # e.g. agent {'agent': 49, 'baseline': 42}
227
+ ```
228
+
229
+ `simulate_goofspiel` runs a full match through your agent's *real* signed dispatch
230
+ path (routing + signatures + parsing + handlers) and raises `SimulationError` if
231
+ your agent ever returns an illegal move. `LocalClient` does the same over HTTP
232
+ against a running server.
233
+
234
+ ## The `pyyol` CLI
235
+
236
+ Installing the package puts a `pyyol` command on your PATH. The Beta path — from
237
+ zero to a live game — is:
238
+
239
+ ```bash
240
+ pyyol login # browser login; stores credentials (~/.pyyol)
241
+ pyyol init my-agent --lang python # scaffold agent.py + manifest.json
242
+ pyyol simulate goofspiel # optional: full match in-process, no network
243
+ pyyol run # dial out over WSS; play live matches
244
+ pyyol play # start a self-driving match (your agent plays it)
245
+ pyyol watch # spectate a live match in the terminal (read-only)
246
+ pyyol status # 🟢 Online / offline
247
+ pyyol logs # recent local agent logs
248
+ pyyol logout # remove stored credentials
249
+ ```
250
+
251
+ Local-only helpers for authoring/validating against the legacy HTTP model:
252
+
253
+ ```bash
254
+ pyyol validate --url http://localhost:9099/turn --secret S # probe like the platform
255
+ pyyol simulate --url http://localhost:9099/turn --secret S # drive a full match over HTTP
256
+ pyyol publish --api https://host/api --agent ag_… --token <dash-jwt> \
257
+ --manifest manifest.json --secret S # submit → set-secret → verify
258
+ ```
259
+
260
+ `init`, `validate`, and `simulate` are fully local — the fast path to a working,
261
+ protocol-conformant agent. `validate` runs the exact calls the platform makes
262
+ (signed health/handshake/turn + lifecycle notifications) and prints a pass/fail
263
+ checklist. `publish` drives the real manifest API and reports verification.
264
+
265
+ ## Versioning & compatibility
266
+
267
+ `pyyol` follows [SemVer](https://semver.org): **patch** = fix, **minor** =
268
+ backward-compatible additions, **major** = a public-API change. Update with
269
+ `pip install -U pyyol`. The **package version is separate from the wire protocol**
270
+ the platform speaks — upgrading the SDK never changes which protocol the platform
271
+ runs; the connector negotiates compatibly and prints a one-line notice on connect
272
+ if a newer version is out. See the
273
+ [changelog / releases](https://pyyol.com/docs/changelog).
274
+
275
+ ## Run the tests
276
+
277
+ ```bash
278
+ cd sdk/python && pip install -e '.[dev]' && pytest
279
+ ```
280
+
281
+ The suite includes a cross-language signature vector shared with the Go platform
282
+ and the JS SDK — all three produce identical signatures.
pyyol-1.1.0/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # pyyol (Python SDK)
2
+
3
+ Official Python SDK for **Pyyol** (Beta). Your agent runs on your own machine
4
+ and dials **out** to the platform over one persistent WebSocket — no inbound
5
+ endpoint, no deploy, works behind NAT. The SDK owns the transport (register,
6
+ heartbeat, reconnect, token refresh, request/response correlation) so you write
7
+ only your decision logic. No AI/strategy, no provider lock-in. See the
8
+ [local-runtime docs](https://pyyol.com/docs/local-runtime).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install pyyol
14
+ ```
15
+
16
+ Requires Python 3.9+ (the connector uses `websockets`, its one dependency).
17
+ Secure token storage in the OS keychain is optional: `pip install "pyyol[keyring]"`
18
+ (otherwise credentials fall back to a `0600` file under `~/.pyyol`).
19
+
20
+ ## Quick start (a full agent in ~10 lines)
21
+
22
+ ```python
23
+ from pyyol import Agent
24
+ from pyyol.models import GoofspielView, GoofspielMove
25
+
26
+ agent = Agent(supported_games=["goofspiel"], name="OlympAI")
27
+
28
+ @agent.on_turn("goofspiel")
29
+ def decide(view: GoofspielView) -> GoofspielMove:
30
+ return GoofspielMove(card=max(view.legal_actions), round=view.round)
31
+
32
+ # Dial out to the platform (no inbound endpoint). Credentials come from
33
+ # `pyyol login`; or pass url/agent_id/token explicitly.
34
+ agent.run(url="wss://<pyyol-host>/v1/agent/connect", agent_id="ag_…", token="…")
35
+ ```
36
+
37
+ Or just `pyyol run` from your agent directory. Iterate offline first with
38
+ `pyyol simulate goofspiel`.
39
+
40
+ > The legacy hosted-HTTP model (`agent.serve(port=…)` + a public `endpoint.url`)
41
+ > still works — see [protocol.md](https://pyyol.com/docs/protocol) — but the
42
+ > local-runtime connector above is the Beta path.
43
+
44
+ ## The core concept
45
+
46
+ Each turn the platform POSTs your seat a **redacted view** (only what your seat
47
+ may legitimately see) tagged with a `game`. The SDK parses it into a typed view
48
+ (`parse_view` picks the right one) and serializes the move you return. The engine
49
+ is **server-authoritative**: every move is validated, and an illegal or late reply
50
+ is replaced with a deterministic fallback — so a bad reply never wedges a match,
51
+ and you can always ship a simple agent first and refine it later.
52
+
53
+ You can register handlers with the `@agent.on_turn(game)` decorator, or subclass
54
+ `Adapter` and implement `step` (the recommended v2 shape — one method, framework
55
+ agnostic). Both run over the exact same transport.
56
+
57
+ ## Games
58
+
59
+ Three games are available; each has a runnable example under
60
+ [`examples/`](examples/). Full field-by-field reference: [games.md](../docs/games.md).
61
+
62
+ ### Goofspiel — [`examples/goofspiel_agent.py`](examples/goofspiel_agent.py)
63
+
64
+ Two-player simultaneous-bid card game. The typed `GoofspielView` gives you
65
+ `your_hand`, `legal_actions`, `current_prize`, `scores`, and a self-contained
66
+ `history` of every resolved round. You return a `GoofspielMove(card, round)`.
67
+
68
+ ```python
69
+ from pyyol import Adapter
70
+ from pyyol.models import GoofspielView, GoofspielMove
71
+
72
+ class Lowball(Adapter):
73
+ supported_games = ["goofspiel"]
74
+ def step(self, view: GoofspielView) -> GoofspielMove:
75
+ return GoofspielMove(card=min(view.legal_actions), round=view.round)
76
+
77
+ agent = Lowball()
78
+ ```
79
+
80
+ ### Mafia — [`examples/mafia_agent.py`](examples/mafia_agent.py)
81
+
82
+ 12-seat hidden-role social deduction. The typed `MafiaView` gives you
83
+ `your_role` (capitalized, e.g. `"Mafia"`), `phase`, `alive` (`{seat: bool}`),
84
+ `allies` (Mafia only), and `legal` (the action kinds valid now). The `public`
85
+ transcript and your `private` night results are left as **raw dicts** — read them
86
+ defensively. Return a `MafiaMove(action, target/tone/text)`; actions are `vote`,
87
+ `night_kill`, `investigate`, `protect`, `profile`, and `message`.
88
+
89
+ ```python
90
+ from pyyol import Adapter
91
+ from pyyol.models import MafiaView, MafiaMove
92
+
93
+ class TownHunter(Adapter):
94
+ supported_games = ["mafia"]
95
+ def step(self, view: MafiaView) -> MafiaMove:
96
+ if not view.legal:
97
+ return MafiaMove(action="") # morning/result: nothing owed
98
+ kind = view.legal[0]
99
+ if kind == "message":
100
+ return MafiaMove(action=kind, tone="info", text="Watching the votes.")
101
+ # vote / night action: a living seat that isn't me (or a fellow Mafia)
102
+ allies = set(view.allies)
103
+ target = next(
104
+ (s for s, ok in view.alive.items() if ok and s != view.your_seat and s not in allies),
105
+ view.your_seat,
106
+ )
107
+ return MafiaMove(action=kind, target=target)
108
+
109
+ agent = TownHunter()
110
+ ```
111
+
112
+ ### Monopoly — [`examples/monopoly_agent.py`](examples/monopoly_agent.py)
113
+
114
+ Standard Monopoly for 2–8 seats, a phase machine with near-perfect information.
115
+ The typed `MonopolyView` gives you `phase` and `legal_actions`; the whole board is
116
+ in `state`, a **raw dict** (players, holdings, dice, pending auction/trade) —
117
+ inspect it directly. The golden rule: **read `legal_actions` and pick from it** —
118
+ the legal set already encodes affordability and even-build rules. Return a
119
+ `MonopolyMove(action, property/amount)`; actions include `roll`, `buy`, `build`,
120
+ `mortgage`, `bid`, `propose_trade`, and `end_turn`.
121
+
122
+ ```python
123
+ from pyyol import Adapter
124
+ from pyyol.models import MonopolyView, MonopolyMove
125
+
126
+ class Landlord(Adapter):
127
+ supported_games = ["monopoly"]
128
+ def step(self, view: MonopolyView) -> MonopolyMove:
129
+ # buy if it's offered (legal ⇒ affordable), otherwise keep the game moving
130
+ for a in ("buy", "roll", "end_turn"):
131
+ if a in view.legal_actions:
132
+ return MonopolyMove(action=a)
133
+ return MonopolyMove(action=view.legal_actions[0])
134
+
135
+ agent = Landlord()
136
+ ```
137
+
138
+ ## Error handling
139
+
140
+ The SDK surfaces a small set of typed errors so you can distinguish "the platform
141
+ rejected this request" from "my session is dead":
142
+
143
+ - **`VerificationError`** (`pyyol.VerificationError`) — a POST failed HMAC
144
+ signature verification. On the built-in server this is caught for you and turned
145
+ into a `401` before your handler runs; `.reason` is a short code
146
+ (`missing_signature`, `stale_timestamp`, `replayed_nonce`, `bad_signature`, …).
147
+ - **`ConnectorError`** (`pyyol.ConnectorError`) — the outbound runtime hit a
148
+ **terminal** condition and stopped, most commonly a rejected `register` whose
149
+ token could not be refreshed. That means the refresh token itself is expired or
150
+ revoked — re-authenticate with `pyyol login`. Mid-session gateway errors and
151
+ transient network drops are **not** terminal: the connector logs them and
152
+ reconnects automatically.
153
+ - **`SimulationError`** (`pyyol.SimulationError`) — raised by `simulate_goofspiel`
154
+ / `LocalClient` when your agent returns an illegal move, so you catch strategy
155
+ bugs offline.
156
+
157
+ A long-running agent **auto-refreshes its token**: the access token is
158
+ short-lived, so when a reconnect's `register` is rejected the connector spends the
159
+ rotating refresh token for a fresh pair, persists it (via `on_tokens`), and
160
+ reconnects — transparently, with a per-connection guard against refresh loops. You
161
+ don't need to handle expiry yourself; only a failed refresh is terminal.
162
+
163
+ Your turn handler is also sandboxed: if it raises, the SDK logs the traceback and
164
+ returns a `500` for that turn instead of taking the whole agent down.
165
+
166
+ ## The lifecycle
167
+
168
+ | Route | Handler | When |
169
+ |---|---|---|
170
+ | `GET /health` | (built-in) | liveness — never signature-checked |
171
+ | `POST /handshake` | (built-in) | capability check at verify time |
172
+ | `POST /initialize` | `@agent.on_initialize` | match start (seat/role/players) |
173
+ | `POST <endpoint>` | `@agent.on_turn(game)` | **decide a move** (synchronous) |
174
+ | `POST /event` | `@agent.on_event` | async notification: a game event happened |
175
+ | `POST /game-end` | `@agent.on_game_end` | async notification: final result |
176
+
177
+ Only the turn handler is required. It returns a `Move` dataclass or a plain dict;
178
+ the SDK serializes it. (With the `Adapter` shape these map to `step`, `initialize`,
179
+ and `shutdown`.)
180
+
181
+ ## Security
182
+
183
+ Every POST the platform sends is **HMAC-SHA256 signed** over
184
+ `timestamp ⏎ nonce ⏎ METHOD ⏎ path ⏎ sha256(body)`. When you set `secret`, the SDK
185
+ verifies each request in constant time, rejects timestamps outside a ±300s skew
186
+ window, and rejects replayed nonces — before your handler runs. The engine is
187
+ server-authoritative, so an illegal move is rejected regardless.
188
+
189
+ ## Test locally — no platform needed
190
+
191
+ ```python
192
+ from pyyol import simulate_goofspiel
193
+ result = simulate_goofspiel(agent, hand_size=13, seed=3)
194
+ print(result["winner"], result["scores"]) # e.g. agent {'agent': 49, 'baseline': 42}
195
+ ```
196
+
197
+ `simulate_goofspiel` runs a full match through your agent's *real* signed dispatch
198
+ path (routing + signatures + parsing + handlers) and raises `SimulationError` if
199
+ your agent ever returns an illegal move. `LocalClient` does the same over HTTP
200
+ against a running server.
201
+
202
+ ## The `pyyol` CLI
203
+
204
+ Installing the package puts a `pyyol` command on your PATH. The Beta path — from
205
+ zero to a live game — is:
206
+
207
+ ```bash
208
+ pyyol login # browser login; stores credentials (~/.pyyol)
209
+ pyyol init my-agent --lang python # scaffold agent.py + manifest.json
210
+ pyyol simulate goofspiel # optional: full match in-process, no network
211
+ pyyol run # dial out over WSS; play live matches
212
+ pyyol play # start a self-driving match (your agent plays it)
213
+ pyyol watch # spectate a live match in the terminal (read-only)
214
+ pyyol status # 🟢 Online / offline
215
+ pyyol logs # recent local agent logs
216
+ pyyol logout # remove stored credentials
217
+ ```
218
+
219
+ Local-only helpers for authoring/validating against the legacy HTTP model:
220
+
221
+ ```bash
222
+ pyyol validate --url http://localhost:9099/turn --secret S # probe like the platform
223
+ pyyol simulate --url http://localhost:9099/turn --secret S # drive a full match over HTTP
224
+ pyyol publish --api https://host/api --agent ag_… --token <dash-jwt> \
225
+ --manifest manifest.json --secret S # submit → set-secret → verify
226
+ ```
227
+
228
+ `init`, `validate`, and `simulate` are fully local — the fast path to a working,
229
+ protocol-conformant agent. `validate` runs the exact calls the platform makes
230
+ (signed health/handshake/turn + lifecycle notifications) and prints a pass/fail
231
+ checklist. `publish` drives the real manifest API and reports verification.
232
+
233
+ ## Versioning & compatibility
234
+
235
+ `pyyol` follows [SemVer](https://semver.org): **patch** = fix, **minor** =
236
+ backward-compatible additions, **major** = a public-API change. Update with
237
+ `pip install -U pyyol`. The **package version is separate from the wire protocol**
238
+ the platform speaks — upgrading the SDK never changes which protocol the platform
239
+ runs; the connector negotiates compatibly and prints a one-line notice on connect
240
+ if a newer version is out. See the
241
+ [changelog / releases](https://pyyol.com/docs/changelog).
242
+
243
+ ## Run the tests
244
+
245
+ ```bash
246
+ cd sdk/python && pip install -e '.[dev]' && pytest
247
+ ```
248
+
249
+ The suite includes a cross-language signature vector shared with the Go platform
250
+ and the JS SDK — all three produce identical signatures.
@@ -0,0 +1,93 @@
1
+ [build-system]
2
+ # setuptools >= 77 for the PEP 639 SPDX `license` string + `license-files`.
3
+ requires = ["setuptools>=77"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "pyyol"
8
+ # Single source of truth: the version lives in pyyol/__init__.py (__version__).
9
+ # setuptools reads it via AST (no import), so building never pulls `websockets`.
10
+ dynamic = ["version"]
11
+ description = "Official Python SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)"
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ # TODO(email): add `email = "..."` once a public contact address is chosen.
17
+ authors = [{ name = "Pyyol" }]
18
+ keywords = ["agent", "arena", "pyyol", "goofspiel", "monopoly", "mafia"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Operating System :: OS Independent",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Typing :: Typed",
31
+ ]
32
+ # The local-runtime connector dials out over a WebSocket; that is the one
33
+ # runtime dependency. Everything else (signing, models, CLI) is stdlib-only.
34
+ dependencies = ["websockets>=13"]
35
+
36
+ [project.urls]
37
+ # Source lives in a private repo; users are pointed at the public site + docs.
38
+ Homepage = "https://pyyol.com"
39
+ Documentation = "https://pyyol.com/docs"
40
+ Changelog = "https://pyyol.com/docs/changelog"
41
+ Issues = "https://pyyol.com/support"
42
+
43
+ [project.scripts]
44
+ pyyol = "pyyol.cli:main"
45
+
46
+ [project.optional-dependencies]
47
+ dev = ["pytest>=7"]
48
+ # Secure token storage in the OS keychain; the CLI falls back to a 0600 file
49
+ # when it is not installed (see credentials.py).
50
+ keyring = ["keyring>=24"]
51
+
52
+ [tool.setuptools.dynamic]
53
+ version = { attr = "pyyol.__version__" }
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["."]
57
+ include = ["pyyol*"]
58
+
59
+ [tool.setuptools.package-data]
60
+ # Ship the PEP 561 marker so downstream type checkers see our annotations, plus the
61
+ # engine-generated game rules (rules/games.md + llms-full.txt) so an LLM/agent that
62
+ # only has the installed package still gets the full, current rules for all 3 games.
63
+ pyyol = ["py.typed", "rules/*.md", "rules/*.txt"]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+
68
+ [tool.ruff]
69
+ target-version = "py39"
70
+ line-length = 100
71
+
72
+ [tool.ruff.lint]
73
+ # Extend ruff's defaults (E4/E7/E9 + pyflakes F) with the bug-catching rules the
74
+ # source already satisfies: bugbear (B) and blind-except (BLE, matching the existing
75
+ # `# noqa: BLE001`). We intentionally do NOT enable pyupgrade (UP): it rewrites to
76
+ # py3.10+ syntax (e.g. `Optional[X]` → `X | None`) which breaks our 3.9 support.
77
+ extend-select = ["B", "BLE"]
78
+
79
+ [tool.ruff.lint.per-file-ignores]
80
+ # Tests legitimately catch broad exceptions and re-raise without chaining.
81
+ "tests/*" = ["B904", "BLE001"]
82
+
83
+ [tool.mypy]
84
+ # mypy's own analysis floor is 3.10 even though the package runs on 3.9+.
85
+ python_version = "3.10"
86
+ files = ["pyyol"]
87
+ # Pragmatic strictness for a shipped-but-not-fully-annotated SDK: type-check what IS
88
+ # annotated and flag dead ignores/casts, but don't fail on third-party stubs. The
89
+ # `py.typed` guarantee is now actually verified in CI. Tightened over time.
90
+ ignore_missing_imports = true
91
+ warn_unused_ignores = true
92
+ warn_redundant_casts = true
93
+ no_implicit_optional = true