pythsol 0.1.0__py3-none-any.whl

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 (76) hide show
  1. pythsol/__init__.py +317 -0
  2. pythsol/cli/__init__.py +7 -0
  3. pythsol/cli/cache.py +78 -0
  4. pythsol/cli/cluster.py +45 -0
  5. pythsol/cli/commands/build.py +214 -0
  6. pythsol/cli/commands/check.py +54 -0
  7. pythsol/cli/commands/client_cmd.py +97 -0
  8. pythsol/cli/commands/deploy.py +103 -0
  9. pythsol/cli/commands/docs_cmd.py +62 -0
  10. pythsol/cli/commands/doctor.py +211 -0
  11. pythsol/cli/commands/emit_c.py +69 -0
  12. pythsol/cli/commands/faucet.py +66 -0
  13. pythsol/cli/commands/idl_cmd.py +69 -0
  14. pythsol/cli/commands/idl_publish.py +80 -0
  15. pythsol/cli/commands/init.py +65 -0
  16. pythsol/cli/commands/port_cmd.py +42 -0
  17. pythsol/cli/commands/profile.py +57 -0
  18. pythsol/cli/commands/serve.py +52 -0
  19. pythsol/cli/commands/test_cmd.py +217 -0
  20. pythsol/cli/commands/verify.py +96 -0
  21. pythsol/cli/commands/wallet.py +128 -0
  22. pythsol/cli/config.py +84 -0
  23. pythsol/cli/main.py +72 -0
  24. pythsol/cli/rpc.py +56 -0
  25. pythsol/cli/template_programs/__init__.py +1 -0
  26. pythsol/cli/template_programs/counter.py +52 -0
  27. pythsol/cli/template_programs/escrow.py +75 -0
  28. pythsol/cli/template_programs/ledger.py +68 -0
  29. pythsol/cli/template_programs/multisig.py +91 -0
  30. pythsol/cli/template_programs/poll.py +70 -0
  31. pythsol/cli/template_programs/vault.py +67 -0
  32. pythsol/cli/templates.py +192 -0
  33. pythsol/client/__init__.py +5 -0
  34. pythsol/client/from_idl.py +356 -0
  35. pythsol/client/gen.py +496 -0
  36. pythsol/client/port.py +355 -0
  37. pythsol/compiler/__init__.py +39 -0
  38. pythsol/compiler/diagnostics.py +130 -0
  39. pythsol/compiler/emit.py +3550 -0
  40. pythsol/compiler/front.py +84 -0
  41. pythsol/compiler/idl.py +465 -0
  42. pythsol/compiler/model.py +1329 -0
  43. pythsol/compiler/modules.py +320 -0
  44. pythsol/compiler/parse.py +59 -0
  45. pythsol/compiler/pipeline.py +152 -0
  46. pythsol/compiler/resolve.py +518 -0
  47. pythsol/compiler/subset.py +1061 -0
  48. pythsol/compiler/toolchain.py +438 -0
  49. pythsol/compiler/typecheck.py +2447 -0
  50. pythsol/playground/__init__.py +7 -0
  51. pythsol/playground/app.css +182 -0
  52. pythsol/playground/app.js +260 -0
  53. pythsol/playground/apple-touch-icon.png +0 -0
  54. pythsol/playground/docs.html +61 -0
  55. pythsol/playground/favicon-32.png +0 -0
  56. pythsol/playground/favicon.ico +0 -0
  57. pythsol/playground/favicon.svg +24 -0
  58. pythsol/playground/hero-bg.webp +0 -0
  59. pythsol/playground/index.html +66 -0
  60. pythsol/playground/landing.html +810 -0
  61. pythsol/playground/playground.html +77 -0
  62. pythsol/playground/robots.txt +4 -0
  63. pythsol/playground/server.py +336 -0
  64. pythsol/playground/site-bg.svg +146 -0
  65. pythsol/playground/site.css +1105 -0
  66. pythsol/py.typed +0 -0
  67. pythsol/refrt/__init__.py +200 -0
  68. pythsol/runtime/__init__.py +17 -0
  69. pythsol/runtime/pythsol_rt.h +1216 -0
  70. pythsol/testing/__init__.py +12 -0
  71. pythsol/testing/plugin.py +437 -0
  72. pythsol-0.1.0.dist-info/METADATA +178 -0
  73. pythsol-0.1.0.dist-info/RECORD +76 -0
  74. pythsol-0.1.0.dist-info/WHEEL +4 -0
  75. pythsol-0.1.0.dist-info/entry_points.txt +6 -0
  76. pythsol-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,12 @@
1
+ """Pytest helpers for programs built by PythSol.
2
+
3
+ Importing this package does not import the compiler. The plugin does, when a
4
+ test asks for the ``program`` fixture.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["DEFAULT_PROGRAM_ID"]
10
+
11
+ #: Program id the LiteSVM fixture loads, unless ``PYTHSOL_PROGRAM_ID`` is set.
12
+ DEFAULT_PROGRAM_ID = "F1x3Lz3Nn3jvrUWM65uwGFPzFGuCB5GMUksMiNGxiKgU"
@@ -0,0 +1,437 @@
1
+ """Pytest plugin: ``svm``, ``program``, ``payer``, ``accounts``, and ``send``.
2
+
3
+ ``pythsol test`` sets ``PYTHSOL_TEST_SO``, ``PYTHSOL_TEST_CLIENT``, and
4
+ ``PYTHSOL_TEST_SOURCE``. A test then calls instructions by keyword:
5
+
6
+ ```python
7
+ def test_increment(program, payer):
8
+ opened = program.initialize(authority=payer)
9
+ counter = opened.account("counter")
10
+ program.increment(authority=payer, counter=counter, by=5)
11
+ assert program.fetch("Counter", counter)["count"] == 5
12
+ ```
13
+
14
+ ``Init`` accounts and a ``bump`` argument are filled in. The signer must be a
15
+ keypair. Failures raise with the program logs, including ``(file.py:line)``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib.util
21
+ import os
22
+ import re
23
+ from collections.abc import Iterator
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import pytest
30
+
31
+ from pythsol.testing import DEFAULT_PROGRAM_ID
32
+
33
+ FAUCET_LAMPORTS = 50_000_000
34
+
35
+ _CUSTOM = re.compile(r"Custom\((\d+)\)")
36
+ _HEX = re.compile(r"custom program error: 0x([0-9a-fA-F]+)")
37
+ _CU = re.compile(r"consumed (\d+) of")
38
+ _SOLDERS: dict[str, Any] = {}
39
+
40
+
41
+ def pytest_configure(config: pytest.Config) -> None:
42
+ """Register the marker used by template tests."""
43
+ config.addinivalue_line("markers", "pythsol: test drives a PythSol program in LiteSVM")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PythsolTarget:
48
+ """A built program the fixtures load."""
49
+
50
+ source: Path
51
+ so: Path
52
+ client: Path
53
+ program_id: str = DEFAULT_PROGRAM_ID
54
+
55
+
56
+ @dataclass
57
+ class CallResult:
58
+ """One ``send``: logs, compute units, and the accounts the call used."""
59
+
60
+ ok: bool
61
+ logs: list[str]
62
+ compute_units: int | None
63
+ error_code: int | None
64
+ accounts: dict[str, Any] = field(default_factory=dict)
65
+
66
+ def account(self, name: str) -> Any:
67
+ """Pubkey passed for the account parameter ``name``."""
68
+ if name not in self.accounts:
69
+ known = ", ".join(sorted(self.accounts))
70
+ raise KeyError(f"no account {name!r} on this call ({known})")
71
+ return self.accounts[name]
72
+
73
+
74
+ class ProgramFailed(AssertionError): # noqa: N818
75
+ """The instruction returned an error and the test did not expect one."""
76
+
77
+
78
+ def _solders() -> dict[str, Any]:
79
+ if _SOLDERS:
80
+ return _SOLDERS
81
+ from solders.keypair import Keypair
82
+ from solders.litesvm import LiteSVM
83
+ from solders.message import Message
84
+ from solders.pubkey import Pubkey
85
+ from solders.transaction import Transaction
86
+ from solders.transaction_metadata import FailedTransactionMetadata
87
+
88
+ _SOLDERS.update(
89
+ {
90
+ "Keypair": Keypair,
91
+ "LiteSVM": LiteSVM,
92
+ "Message": Message,
93
+ "Pubkey": Pubkey,
94
+ "Transaction": Transaction,
95
+ "Failed": FailedTransactionMetadata,
96
+ }
97
+ )
98
+ return _SOLDERS
99
+
100
+
101
+ def _need_solders() -> None:
102
+ try:
103
+ _solders()
104
+ except ImportError:
105
+ if os.environ.get("PYTHSOL_TEST_SO"):
106
+ pytest.fail("solders is required for pythsol test; pip install 'pythsol[test]'")
107
+ pytest.skip("solders is not installed")
108
+
109
+
110
+ def _is_keypair(value: object) -> bool:
111
+ return callable(getattr(value, "pubkey", None)) and callable(
112
+ getattr(value, "sign_message", None)
113
+ )
114
+
115
+
116
+ def _as_pubkey(value: Any) -> Any:
117
+ pubkey = getattr(value, "pubkey", None)
118
+ if callable(pubkey):
119
+ return pubkey()
120
+ return value
121
+
122
+
123
+ def _logs(result: Any) -> list[str]:
124
+ try:
125
+ logs = getattr(result, "logs", None)
126
+ if callable(logs):
127
+ logs = logs()
128
+ else:
129
+ meta = getattr(result, "meta", None)
130
+ meta = meta() if callable(meta) else meta
131
+ logs = getattr(meta, "logs", None)
132
+ logs = logs() if callable(logs) else logs
133
+ return [str(entry) for entry in (logs or [])]
134
+ except Exception:
135
+ return []
136
+
137
+
138
+ def _failed(result: Any) -> bool:
139
+ if isinstance(result, _solders()["Failed"]):
140
+ return True
141
+ err = getattr(result, "err", None)
142
+ if callable(err):
143
+ err = err()
144
+ return err is not None
145
+
146
+
147
+ def _error_code(result: Any) -> int | None:
148
+ err = getattr(result, "err", None)
149
+ if err is None:
150
+ return None
151
+ if callable(err):
152
+ err = err()
153
+ if err is None:
154
+ return None
155
+ text = str(err)
156
+ digits = _CUSTOM.search(text)
157
+ if digits:
158
+ return int(digits.group(1), 10)
159
+ hexa = _HEX.search(text)
160
+ if hexa:
161
+ return int(hexa.group(1), 16)
162
+ return None
163
+
164
+
165
+ def _compute_units(result: Any, logs: list[str]) -> int | None:
166
+ meta = getattr(result, "meta", None)
167
+ if callable(meta):
168
+ try:
169
+ meta = meta()
170
+ except Exception:
171
+ meta = None
172
+ for obj in (result, meta):
173
+ if obj is None:
174
+ continue
175
+ for attr in ("compute_units_consumed", "compute_units"):
176
+ value = getattr(obj, attr, None)
177
+ if callable(value):
178
+ try:
179
+ value = value()
180
+ except Exception:
181
+ continue
182
+ if isinstance(value, int):
183
+ return value
184
+ for line in logs:
185
+ match = _CU.search(line)
186
+ if match:
187
+ return int(match.group(1))
188
+ return None
189
+
190
+
191
+ def _load_client(path: Path) -> Any:
192
+ spec = importlib.util.spec_from_file_location(f"pythsol_client_{path.stem}_{id(path)}", path)
193
+ if spec is None or spec.loader is None:
194
+ raise RuntimeError(f"cannot load client {path}")
195
+ module = importlib.util.module_from_spec(spec)
196
+ spec.loader.exec_module(module)
197
+ return module
198
+
199
+
200
+ class LoadedProgram:
201
+ """One LiteSVM plus the generated client for a built program."""
202
+
203
+ def __init__(self, svm: Any, payer: Any, target: PythsolTarget) -> None:
204
+ from pythsol.compiler.model import ProgramModel
205
+ from pythsol.compiler.modules import read_sibling_modules
206
+ from pythsol.compiler.pipeline import emit_program
207
+
208
+ text = target.source.read_text(encoding="utf-8")
209
+ modules, module_files = read_sibling_modules(target.source)
210
+ generated = emit_program(
211
+ text,
212
+ target.source,
213
+ modules=modules,
214
+ module_files=module_files,
215
+ )
216
+ if not generated.ok or generated.generated is None:
217
+ rendered = "\n".join(item.render() for item in generated.diagnostics)
218
+ pytest.fail(f"program did not check:\n{rendered}")
219
+ model: ProgramModel = generated.generated
220
+ self.svm = svm
221
+ self.payer = payer
222
+ self.target = target
223
+ self.model = model
224
+ self.name = model.name
225
+ self.client = _load_client(target.client)
226
+ self.program_id = _solders()["Pubkey"].from_string(target.program_id)
227
+ self.addresses: dict[str, Any] = {}
228
+ self.instruction_names = tuple(instr.snake_name for instr in model.instructions)
229
+ self._errors = {err.name: err.code for err in model.errors}
230
+ self._expect: int | None = None
231
+ self._last: CallResult | None = None
232
+
233
+ def keypair(self) -> Any:
234
+ """A new funded keypair on this LiteSVM."""
235
+ kp = _solders()["Keypair"]()
236
+ self.svm.airdrop(kp.pubkey(), FAUCET_LAMPORTS)
237
+ return kp
238
+
239
+ def fetch(self, account: str, address: Any) -> dict[str, Any]:
240
+ """Decode ``account`` at ``address`` with the generated client."""
241
+ from pythsol.compiler.model import snake_case
242
+
243
+ found = self.svm.get_account(address)
244
+ if found is None or not bytes(found.data):
245
+ raise AssertionError(f"{account} at {address} is empty")
246
+ fn = getattr(self.client, f"decode_{snake_case(account)}")
247
+ decoded: dict[str, Any] = fn(bytes(found.data))
248
+ return decoded
249
+
250
+ def send_ix(self, ix: Any, signers: list[Any]) -> CallResult:
251
+ """Send a solders instruction. ``signers[0]`` pays the fee."""
252
+ if not signers:
253
+ raise TypeError("send needs at least one signer")
254
+ expire = getattr(self.svm, "expire_blockhash", None)
255
+ if callable(expire):
256
+ expire()
257
+ blockhash = self.svm.latest_blockhash()
258
+ message = _solders()["Message"].new_with_blockhash([ix], signers[0].pubkey(), blockhash)
259
+ tx = _solders()["Transaction"](signers, message, blockhash)
260
+ raw = self.svm.send_transaction(tx)
261
+ return self._result(raw, {})
262
+
263
+ @contextmanager
264
+ def expect_error(self, name: str | int) -> Iterator[None]:
265
+ """Fail unless the next instruction returns this custom error."""
266
+ expected = name if isinstance(name, int) else self._resolve_error(name)
267
+ if self._expect is not None:
268
+ raise RuntimeError("expect_error cannot be nested")
269
+ self._expect = expected
270
+ self._last = None
271
+ try:
272
+ yield
273
+ except Exception:
274
+ raise
275
+ else:
276
+ last = self._last
277
+ got = None if last is None else last.error_code
278
+ logs = "" if last is None else "\n".join(last.logs)
279
+ if last is None or last.ok or last.error_code != expected:
280
+ raise AssertionError(f"expected error {name} ({expected}), got {got}\n{logs}")
281
+ finally:
282
+ self._expect = None
283
+
284
+ def __getattr__(self, name: str) -> Any:
285
+ instr = self._instruction(name)
286
+ if instr is None:
287
+ raise AttributeError(name)
288
+
289
+ def call(**kwargs: Any) -> CallResult:
290
+ return self._invoke(instr, kwargs)
291
+
292
+ return call
293
+
294
+ def _instruction(self, name: str) -> Any:
295
+ for instr in self.model.instructions:
296
+ if instr.name == name or instr.snake_name == name:
297
+ return instr
298
+ return None
299
+
300
+ def _resolve_error(self, name: str) -> int:
301
+ if name in self._errors:
302
+ return self._errors[name]
303
+ for err_name, code in self._errors.items():
304
+ if name == err_name or name.endswith("." + err_name):
305
+ return code
306
+ known = ", ".join(sorted(self._errors))
307
+ raise KeyError(f"unknown error {name!r} ({known})")
308
+
309
+ def _invoke(self, instr: Any, kwargs: dict[str, Any]) -> CallResult:
310
+ from pythsol.compiler.model import snake_case
311
+
312
+ account_names = {param.name for param in instr.account_params}
313
+ data_names = {param.name for param in instr.data_params}
314
+ unknown = set(kwargs) - account_names - data_names
315
+ if unknown:
316
+ raise TypeError(f"unknown argument {sorted(unknown)[0]}")
317
+ pubkeys: dict[str, Any] = {}
318
+ signers: list[Any] = []
319
+ seen: set[str] = set()
320
+ bumps: list[int] = []
321
+ used: dict[str, Any] = {}
322
+ for param in instr.account_params:
323
+ if param.name in kwargs:
324
+ value = kwargs[param.name]
325
+ if param.kind == "signer" and not _is_keypair(value):
326
+ raise TypeError(f"{param.name} must be a keypair")
327
+ if _is_keypair(value) and param.kind == "signer":
328
+ identity = str(value.pubkey())
329
+ if identity not in seen:
330
+ seen.add(identity)
331
+ signers.append(value)
332
+ pubkey = _as_pubkey(value)
333
+ elif param.is_init or param.seed:
334
+ seed = param.seed or snake_case(param.account)
335
+ pubkey, bump = _solders()["Pubkey"].find_program_address(
336
+ [seed.encode()],
337
+ self.program_id,
338
+ )
339
+ bumps.append(int(bump))
340
+ else:
341
+ raise TypeError(f"missing account {param.name}")
342
+ pubkeys[param.name] = pubkey
343
+ used[param.name] = pubkey
344
+ values: list[Any] = []
345
+ for param in instr.data_params:
346
+ if param.name in kwargs:
347
+ value = kwargs[param.name]
348
+ values.append(_as_pubkey(value) if _is_keypair(value) else value)
349
+ elif param.name == "bump" and len(bumps) == 1:
350
+ values.append(bumps[0])
351
+ else:
352
+ raise TypeError(f"missing argument {param.name}")
353
+ if not signers:
354
+ raise TypeError("pass a keypair for the signer")
355
+ fn = getattr(self.client, instr.snake_name)
356
+ ix = fn(self.program_id, *[pubkeys[param.name] for param in instr.account_params], *values)
357
+ raw = self._send_raw(ix, signers)
358
+ result = self._result(raw, used)
359
+ self.addresses.update(used)
360
+ self._last = result
361
+ if not result.ok and self._expect is None:
362
+ raise ProgramFailed(self._failure_text(result))
363
+ return result
364
+
365
+ def _send_raw(self, ix: Any, signers: list[Any]) -> Any:
366
+ expire = getattr(self.svm, "expire_blockhash", None)
367
+ if callable(expire):
368
+ expire()
369
+ blockhash = self.svm.latest_blockhash()
370
+ message = _solders()["Message"].new_with_blockhash([ix], signers[0].pubkey(), blockhash)
371
+ tx = _solders()["Transaction"](signers, message, blockhash)
372
+ return self.svm.send_transaction(tx)
373
+
374
+ def _result(self, raw: Any, accounts: dict[str, Any]) -> CallResult:
375
+ logs = _logs(raw)
376
+ ok = not _failed(raw)
377
+ return CallResult(
378
+ ok=ok,
379
+ logs=logs,
380
+ compute_units=_compute_units(raw, logs),
381
+ error_code=None if ok else _error_code(raw),
382
+ accounts=dict(accounts),
383
+ )
384
+
385
+ def _failure_text(self, result: CallResult) -> str:
386
+ body = "\n".join(result.logs)
387
+ return f"program failed with {result.error_code}\n{body}"
388
+
389
+
390
+ @pytest.fixture(scope="module")
391
+ def pythsol_target() -> PythsolTarget:
392
+ """Built artifacts. ``pythsol test`` sets the environment this reads."""
393
+ so = os.environ.get("PYTHSOL_TEST_SO")
394
+ source = os.environ.get("PYTHSOL_TEST_SOURCE")
395
+ client = os.environ.get("PYTHSOL_TEST_CLIENT")
396
+ if not so or not source or not client:
397
+ pytest.skip("PYTHSOL_TEST_SO is not set; run pythsol test")
398
+ program_id = os.environ.get("PYTHSOL_PROGRAM_ID") or DEFAULT_PROGRAM_ID
399
+ return PythsolTarget(Path(source), Path(so), Path(client), program_id)
400
+
401
+
402
+ @pytest.fixture
403
+ def svm(pythsol_target: PythsolTarget) -> Any:
404
+ """A fresh LiteSVM with the program loaded."""
405
+ _need_solders()
406
+ vm = _solders()["LiteSVM"]()
407
+ program_id = _solders()["Pubkey"].from_string(pythsol_target.program_id)
408
+ vm.add_program(program_id, pythsol_target.so.read_bytes())
409
+ return vm
410
+
411
+
412
+ @pytest.fixture
413
+ def payer(svm: Any) -> Any:
414
+ """A funded fee-payer keypair."""
415
+ _need_solders()
416
+ kp = _solders()["Keypair"]()
417
+ svm.airdrop(kp.pubkey(), FAUCET_LAMPORTS)
418
+ return kp
419
+
420
+
421
+ @pytest.fixture
422
+ def program(svm: Any, payer: Any, pythsol_target: PythsolTarget) -> LoadedProgram:
423
+ """Instruction helpers bound to ``svm``."""
424
+ _need_solders()
425
+ return LoadedProgram(svm, payer, pythsol_target)
426
+
427
+
428
+ @pytest.fixture
429
+ def accounts(program: LoadedProgram) -> dict[str, Any]:
430
+ """Account name to pubkey, filled in as instructions run."""
431
+ return program.addresses
432
+
433
+
434
+ @pytest.fixture
435
+ def send(program: LoadedProgram) -> Any:
436
+ """``send(ix, signers)`` for a hand-built instruction."""
437
+ return program.send_ix
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.5
2
+ Name: pythsol
3
+ Version: 0.1.0
4
+ Summary: PythSol: write Solana programs in a typed Python subset, compiled to native SBF.
5
+ Project-URL: Homepage, https://github.com/PythSol/pythsol
6
+ Project-URL: Repository, https://github.com/PythSol/pythsol
7
+ Project-URL: Issues, https://github.com/PythSol/pythsol/issues
8
+ Project-URL: Changelog, https://github.com/PythSol/pythsol/blob/main/CHANGELOG.md
9
+ Author: PythSol contributors
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: click>=8.1
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == 'dev'
25
+ Requires-Dist: hatchling>=1.24; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pyright>=1.1.350; extra == 'dev'
28
+ Requires-Dist: ruff>=0.5; extra == 'dev'
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest-cov>=4; extra == 'test'
31
+ Requires-Dist: pytest>=8; extra == 'test'
32
+ Requires-Dist: solana>=0.41; extra == 'test'
33
+ Requires-Dist: solders<0.30,>=0.27; extra == 'test'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # PythSol
37
+
38
+ Write Solana programs in **typed Python**, compiled to native SBF bytecode — no Rust on your machine.
39
+
40
+ > **Status: compiler through SPL token CPI, plus the local CLI.** `pythsol check`
41
+ > type-checks a typed Python subset. `pythsol build` compiles it to a native
42
+ > SBF `.so`, an Anchor-style IDL, and a solders client. A second build of the
43
+ > same C prints `cache hit (skipped clang)`. `pythsol test` runs pytest against
44
+ > LiteSVM. The counter and ledger examples run there, and so do a one-byte
45
+ > `@enum` gate, a fixed-width `Option[u64]` note, a `FixedString[8]` label, a
46
+ > till whose fee is an inlined helper, a spot account whose `Point` is packed
47
+ > inline, a vault whose PDA seed, has-one, address, owner, and close are
48
+ > checked by the program, a bell that `emit`s a fixed-width event, a pay
49
+ > instruction whose `system_transfer` moves lamports, and a token move whose
50
+ > `token_transfer`, `token2022_transfer`, and `ata_create` CPIs run against
51
+ > the programs LiteSVM already loads. A program may split across sibling
52
+ > modules (`from acct import Counter`); those files are parsed and are never
53
+ > imported or executed.
54
+ > `pythsol serve` can also compile that program to a `.so` on your machine
55
+ > when the SBF tools are installed.
56
+ > `Vec[T, N]` is a fixed-capacity list. A data enum is a tag plus one
57
+ > reserved scalar (`Fill: u64`), so later fields stay put. A
58
+ > `system_transfer` from a program PDA moves lamports directly. Still ahead:
59
+ > unbounded `Vec[T]`, in-browser SBF compile, and translated docs. Host the
60
+ > static site from `web/docs/dist` after `npm run build`. Host the full
61
+ > playground (check + emit + compile) with Docker — see
62
+ > `docs/guides/hosting.md` (also `/v1/compile` behind `--allow-compile`).
63
+ > Without that flag a public server checks and emits only. The checklist is
64
+ > `docs/STATUS.md`. This tree is version 0.1.0 and is not a release.
65
+
66
+ Source of truth: `docs/spec/PRD.md` (scope) + `docs/spec/TRD.md` (how).
67
+ When possible ADRs under `docs/decisions/` record every assumption that was validated.
68
+
69
+ ```python
70
+ from pythsol import (
71
+ Init, Mut, ProgramError, Pubkey, Signer, account, error, instruction, require, u64, u8,
72
+ )
73
+
74
+ @account
75
+ class Counter:
76
+ authority: Pubkey
77
+ count: u64
78
+ bump: u8
79
+
80
+ @error
81
+ class CounterError(ProgramError):
82
+ Unauthorized = 100
83
+ Overflow = 101
84
+
85
+ @instruction
86
+ def increment(authority: Signer, counter: Mut[Counter], by: u64) -> None:
87
+ require(counter.authority == authority.key, CounterError.Unauthorized)
88
+ counter.count += by # overflow-checked
89
+ ```
90
+
91
+ ## Install
92
+
93
+ ```bash
94
+ pip install -e ".[test]" # developer install, with pytest and solders
95
+ pip install pythsol # compiler and CLI only (when published)
96
+ ```
97
+
98
+ `pythsol test` needs the `test` extra. Until you reinstall the editable package,
99
+ the pytest plugin is not on the entry-point list. `pythsol test` still loads it
100
+ with `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1` and `-p pythsol.testing.plugin`.
101
+
102
+ ## CLI
103
+
104
+ ```bash
105
+ pythsol doctor # check toolchain, Python, RPC
106
+ pythsol init app --template counter # or ledger, vault, escrow
107
+ pythsol check counter.py # full front-end type check; exit 1 with PSxxxx diagnostics
108
+ pythsol check --json counter.py
109
+ pythsol idl counter.py # Anchor-style IDL, no SBF toolchain
110
+ pythsol build counter.py # native SBF .so + IDL + client + manifest
111
+ pythsol test # build, then pytest on LiteSVM
112
+ pythsol client counter.py -o client.py
113
+ pythsol cpi counter.py -o cpi.py # off-chain builders; not an on-chain invoke
114
+ pythsol port idl.json -o sketch.py # skeleton with pass bodies
115
+ pythsol deploy --dry-run --so prog.so # prints solana program deploy; mainnet needs --yes
116
+ pythsol faucet --amount 1 # devnet only, at most 5 SOL
117
+ pythsol wallet new --outfile id.json # secret stays off the terminal unless --show-secret
118
+ pythsol verify --left a.so --right b.so # exit 0 match, exit 3 mismatch
119
+ pythsol profile --logs run.log # reads "consumed N of" lines already in the log
120
+ pythsol idl-publish --address <ID> --dry-run
121
+ pythsol serve # loopback playground, 127.0.0.1:8765
122
+ pythsol docs # offline command list
123
+ pysol doctor # `pysol` is the spec-draft alias of the same CLI
124
+ ```
125
+
126
+ ## Build
127
+
128
+ `pythsol build programs/counter.py` emits `target/pythsol/counter/`:
129
+
130
+ | file | contents |
131
+ |-----------------|---------------------------------------------------------------|
132
+ | `counter.c` | auditable C generated from your program (never hand-edited) |
133
+ | `counter.so` | the SBF ELF deployed on-chain |
134
+ | `counter.json` | Anchor-style legacy IDL |
135
+ | `counter_client.py` | solders `Instruction` builders and account decoders |
136
+ | `manifest.json` | sha256 of c/idl/so/client, toolchain info, and `cache` (`hit`, `miss`, or `off`) |
137
+
138
+ On-chain guarantees: `require()` failures log `(file:line)` and return
139
+ `6000 + index`; integer arithmetic never wraps (checked ops abort with 1009/1010);
140
+ `Init` accounts are PDAs created through a System-program CPI with your seeds.
141
+ The binary ABI conventions are pinned in
142
+ [ADR-0010](docs/decisions/ADR-0010-runtime-abi-and-input-layout.md).
143
+
144
+ ## Phases
145
+
146
+ | Phase | Deliverable | Status |
147
+ |---|---|---|
148
+ | 0 | monorepo skeleton, ADRs, CI, license, name spike | **done** |
149
+ | 1 | SBF toolchain locator + doctor, C→SBF spike, LiteSVM spike | **done** |
150
+ | 2 | parse/subset/type-check front-end, `pythsol check`, 40+ negative fixtures | **done** |
151
+ | 3 | ProgramModel, IDL, C runtime/emitter, `pythsol build`, LiteSVM acceptance | **done** |
152
+ | 4 | if/while/for, casts, full integer widths, `FixedArray`, checked expressions | **done** |
153
+ | 5 | Python client (`*_client.py`, solders) | **done** |
154
+ | 6 | `pythsol test`, pytest plugin, build cache, wallet, faucet, deploy, `pysol.toml` | **done** |
155
+ | 7 | Starlight scaffold and light tokens (`web/docs`, `web/shared-tokens`) | **done** (live at `/docs`; no Lighthouse run) |
156
+ | 8 | `pythsol serve` loopback check, emit, and compile | **live check/emit** (hosted compile pending Render) |
157
+ | 9 | gallery of the six examples, `pythsol port`, migration and contributor notes | **partial** (six examples, not twenty) |
158
+ | 10 | `verify`, `profile`, `idl-publish`, `cpi`, VS Code diagnostics | **partial** (no audit, no CU gate) |
159
+ | 11 | docs translations, a second IR, in-browser SBF | **not scheduled** |
160
+ | … | unit enums (`@enum`, `match`) | **done** |
161
+ | … | fixed-width `Option[T]` (tag + reserved value) | **done** |
162
+ | … | fixed-width `FixedString[N]` (`u32` length + reserved bytes) | **done** |
163
+ | … | plain helpers (inlined, not instructions) | **done** |
164
+ | … | nested `@struct` (fixed width, inline, no discriminator) | **done** |
165
+ | … | constraints `Seeds` / `HasOne` / `Address` / `Owner` / `CloseTo` | **done** |
166
+ | … | alias refusal peels constraint wrappers | **done** |
167
+ | … | `@event` and `emit` (fixed-width log) | **done** |
168
+ | … | `system_transfer` (System program CPI) | **done** |
169
+ | … | SPL Token, Token-2022, and ATA CPIs | **done** |
170
+ | … | multi-file programs (sibling modules, parsed not executed) | **done** |
171
+ | … | `Vec[T, N]`, fixed-width data enums, PDA lamport move | **done** |
172
+ | … | hosted playground deploy configs (Docker/Fly/Render, `--allow-compile`) | **done** |
173
+ | … | browser-side devnet deploy button | later |
174
+ | … | unbounded `Vec[T]`, in-browser WASM clang | later |
175
+
176
+ ## License
177
+
178
+ Apache-2.0. See [LICENSE](./LICENSE) and [docs/decisions/ADR-0002](docs/decisions/ADR-0002-license.md).