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.
- pythsol/__init__.py +317 -0
- pythsol/cli/__init__.py +7 -0
- pythsol/cli/cache.py +78 -0
- pythsol/cli/cluster.py +45 -0
- pythsol/cli/commands/build.py +214 -0
- pythsol/cli/commands/check.py +54 -0
- pythsol/cli/commands/client_cmd.py +97 -0
- pythsol/cli/commands/deploy.py +103 -0
- pythsol/cli/commands/docs_cmd.py +62 -0
- pythsol/cli/commands/doctor.py +211 -0
- pythsol/cli/commands/emit_c.py +69 -0
- pythsol/cli/commands/faucet.py +66 -0
- pythsol/cli/commands/idl_cmd.py +69 -0
- pythsol/cli/commands/idl_publish.py +80 -0
- pythsol/cli/commands/init.py +65 -0
- pythsol/cli/commands/port_cmd.py +42 -0
- pythsol/cli/commands/profile.py +57 -0
- pythsol/cli/commands/serve.py +52 -0
- pythsol/cli/commands/test_cmd.py +217 -0
- pythsol/cli/commands/verify.py +96 -0
- pythsol/cli/commands/wallet.py +128 -0
- pythsol/cli/config.py +84 -0
- pythsol/cli/main.py +72 -0
- pythsol/cli/rpc.py +56 -0
- pythsol/cli/template_programs/__init__.py +1 -0
- pythsol/cli/template_programs/counter.py +52 -0
- pythsol/cli/template_programs/escrow.py +75 -0
- pythsol/cli/template_programs/ledger.py +68 -0
- pythsol/cli/template_programs/multisig.py +91 -0
- pythsol/cli/template_programs/poll.py +70 -0
- pythsol/cli/template_programs/vault.py +67 -0
- pythsol/cli/templates.py +192 -0
- pythsol/client/__init__.py +5 -0
- pythsol/client/from_idl.py +356 -0
- pythsol/client/gen.py +496 -0
- pythsol/client/port.py +355 -0
- pythsol/compiler/__init__.py +39 -0
- pythsol/compiler/diagnostics.py +130 -0
- pythsol/compiler/emit.py +3550 -0
- pythsol/compiler/front.py +84 -0
- pythsol/compiler/idl.py +465 -0
- pythsol/compiler/model.py +1329 -0
- pythsol/compiler/modules.py +320 -0
- pythsol/compiler/parse.py +59 -0
- pythsol/compiler/pipeline.py +152 -0
- pythsol/compiler/resolve.py +518 -0
- pythsol/compiler/subset.py +1061 -0
- pythsol/compiler/toolchain.py +438 -0
- pythsol/compiler/typecheck.py +2447 -0
- pythsol/playground/__init__.py +7 -0
- pythsol/playground/app.css +182 -0
- pythsol/playground/app.js +260 -0
- pythsol/playground/apple-touch-icon.png +0 -0
- pythsol/playground/docs.html +61 -0
- pythsol/playground/favicon-32.png +0 -0
- pythsol/playground/favicon.ico +0 -0
- pythsol/playground/favicon.svg +24 -0
- pythsol/playground/hero-bg.webp +0 -0
- pythsol/playground/index.html +66 -0
- pythsol/playground/landing.html +810 -0
- pythsol/playground/playground.html +77 -0
- pythsol/playground/robots.txt +4 -0
- pythsol/playground/server.py +336 -0
- pythsol/playground/site-bg.svg +146 -0
- pythsol/playground/site.css +1105 -0
- pythsol/py.typed +0 -0
- pythsol/refrt/__init__.py +200 -0
- pythsol/runtime/__init__.py +17 -0
- pythsol/runtime/pythsol_rt.h +1216 -0
- pythsol/testing/__init__.py +12 -0
- pythsol/testing/plugin.py +437 -0
- pythsol-0.1.0.dist-info/METADATA +178 -0
- pythsol-0.1.0.dist-info/RECORD +76 -0
- pythsol-0.1.0.dist-info/WHEEL +4 -0
- pythsol-0.1.0.dist-info/entry_points.txt +6 -0
- 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).
|