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
pythsol/__init__.py ADDED
@@ -0,0 +1,317 @@
1
+ """Typed DSL surface that programs import from.
2
+
3
+ This file is intentionally *importable only as types*: the compiler parses the
4
+ import with `ast` and never executes it, while mypy/pyright use it to
5
+ type-check programs (`py.typed` marker lives beside the package). Phase 3 adds
6
+ the actual runtime semantics — this slice is what Phase 2 accepts.
7
+
8
+ Every program may import:
9
+ * width types: u8,u16,u32,u64,u128 and i8..i128
10
+ * atoms: bool, Pubkey, Signer, SystemProgram
11
+ * wrappers: Mut[T], Init[T], InitIfNeeded[T], Zeroed[T], Account[T]
12
+ * collections: Option[T], Vec[T], FixedArray[T, N], FixedString[N]
13
+ * decorators: @instruction, @account, @struct, @event, @error, @enum
14
+ * accounts: TokenAccount (writable), TokenMint (read-only), no owner check
15
+ * helpers: require, emit, system_transfer, token_transfer,
16
+ token2022_transfer, ata_create
17
+ * `ProgramError` base class
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Generic, TypeAlias, TypeVar
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "Account",
28
+ "Address",
29
+ "CloseTo",
30
+ "FixedArray",
31
+ "FixedString",
32
+ "HasOne",
33
+ "Init",
34
+ "InitIfNeeded",
35
+ "Mut",
36
+ "Option",
37
+ "Owner",
38
+ "ProgramError",
39
+ "Pubkey",
40
+ "Seeds",
41
+ "Signer",
42
+ "SystemProgram",
43
+ "TokenAccount",
44
+ "TokenMint",
45
+ "Vec",
46
+ "Zeroed",
47
+ "account",
48
+ "ata_create",
49
+ "emit",
50
+ "enum",
51
+ "error",
52
+ "event",
53
+ "i8",
54
+ "i16",
55
+ "i32",
56
+ "i64",
57
+ "i128",
58
+ "instruction",
59
+ "require",
60
+ "struct",
61
+ "system_transfer",
62
+ "token2022_transfer",
63
+ "token_transfer",
64
+ "u8",
65
+ "u16",
66
+ "u32",
67
+ "u64",
68
+ "u128",
69
+ ]
70
+
71
+ _T = TypeVar("_T")
72
+ _N = TypeVar("_N")
73
+
74
+ # widths ---------------------------------------------------------------
75
+ u8: TypeAlias = int
76
+ u16: TypeAlias = int
77
+ u32: TypeAlias = int
78
+ u64: TypeAlias = int
79
+ u128: TypeAlias = int
80
+ i8: TypeAlias = int
81
+ i16: TypeAlias = int
82
+ i32: TypeAlias = int
83
+ i64: TypeAlias = int
84
+ i128: TypeAlias = int
85
+
86
+
87
+ # atoms ----------------------------------------------------------------
88
+ class Pubkey:
89
+ """An opaque 32-byte on-chain identity."""
90
+
91
+ key: Pubkey
92
+
93
+
94
+ class Signer(Pubkey):
95
+ """Binding contract: this account must be a required signer."""
96
+
97
+ key: Pubkey
98
+
99
+
100
+ class SystemProgram:
101
+ """Anchor `system_program` stand-in used as a `@account Permissioned type`."""
102
+
103
+ program_id: Pubkey
104
+
105
+
106
+ class TokenAccount:
107
+ """A writable SPL token account. The program does not check its owner."""
108
+
109
+
110
+ class TokenMint:
111
+ """A read-only mint. The program does not check its owner."""
112
+
113
+
114
+ class ProgramError:
115
+ """Base class of `@error` program errors."""
116
+
117
+ message: str | None = None
118
+
119
+
120
+ # wrappers -------------------------------------------------------------
121
+ class Mut(Generic[_T]):
122
+ """Flag: this instruction can write to the wrapped type."""
123
+
124
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
125
+ ...
126
+
127
+ def __setattr__(self, name: str, value: Any) -> None: # pragma: no cover
128
+ ...
129
+
130
+
131
+ class Init(Mut[_T]):
132
+ """Marks this instruction as the one that initializes the account."""
133
+
134
+
135
+ class InitIfNeeded(Mut[_T]):
136
+ """New-account-or-existing? Runtime-conditional."""
137
+
138
+
139
+ class Zeroed(Mut[_T]):
140
+ """The account must be zeroed before this instruction runs."""
141
+
142
+
143
+ class Account(Generic[_T]):
144
+ """Read-only account wrapper."""
145
+
146
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
147
+ ...
148
+
149
+
150
+ class Seeds(Generic[_T, _N]):
151
+ """``Seeds[Init[T], "seed"]`` — the PDA seed, instead of the account name.
152
+
153
+ The second argument is a string literal. Type checkers treat that literal
154
+ as a forward reference, so a program line carries ``type: ignore`` and
155
+ ``noqa: F821``. Attribute access is untyped, like ``Mut``.
156
+ """
157
+
158
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
159
+ ...
160
+
161
+ def __setattr__(self, name: str, value: Any) -> None: # pragma: no cover
162
+ ...
163
+
164
+
165
+ class HasOne(Generic[_T, _N]):
166
+ """``HasOne[Mut[T], "field"]`` — ``account.field`` equals that account's key."""
167
+
168
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
169
+ ...
170
+
171
+ def __setattr__(self, name: str, value: Any) -> None: # pragma: no cover
172
+ ...
173
+
174
+
175
+ class CloseTo(Generic[_T, _N]):
176
+ """``CloseTo[Mut[T], dest]`` — after a successful body, lamports move to ``dest``."""
177
+
178
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
179
+ ...
180
+
181
+ def __setattr__(self, name: str, value: Any) -> None: # pragma: no cover
182
+ ...
183
+
184
+
185
+ class Address(Generic[_T, _N]):
186
+ """``Address[Account[T], expected]`` — the account key equals a ``Pubkey`` argument."""
187
+
188
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
189
+ ...
190
+
191
+
192
+ class Owner(Generic[_T, _N]):
193
+ """``Owner[Account[T], program]`` — ``account.owner`` equals that account's key."""
194
+
195
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
196
+ ...
197
+
198
+
199
+ # collections ----------------------------------------------------------
200
+ class Option(Generic[_T]):
201
+ """Fixed-width optional value: a tag byte plus the inner value, always reserved.
202
+
203
+ ``None`` clears the tag. Assigning a ``T`` sets it. ``.is_some`` reads the
204
+ tag; ``.value`` reads the inner value and aborts when the tag is clear.
205
+ This is not Borsh's short ``None`` (one zero byte). Later fields stay put.
206
+ """
207
+
208
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
209
+ ...
210
+
211
+
212
+ class Vec(Generic[_T]):
213
+ """Account-resizing vector (Anchor `Vec[T]`)."""
214
+
215
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
216
+ ...
217
+
218
+
219
+ class FixedArray(Generic[_T, _N]):
220
+ """Fixed-length typed array. ``N`` in ``FixedArray[T, N]`` is an integer literal."""
221
+
222
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
223
+ ...
224
+
225
+
226
+ class FixedString(Generic[_N]):
227
+ """Fixed-width string: a ``u32`` byte length plus ``N`` reserved bytes.
228
+
229
+ Store a string literal whose UTF-8 form fits in ``N``. ``len`` reads the
230
+ stored byte count. This is not Borsh's short string: the ``N`` bytes stay
231
+ reserved so later fields do not move.
232
+ """
233
+
234
+ def __getattribute__(self, name: str) -> Any: # pragma: no cover
235
+ ...
236
+
237
+
238
+ # decorators -----------------------------------------------------------
239
+ def instruction(func: Any) -> Any:
240
+ """Mark a function as an instruction entry point."""
241
+ return func
242
+
243
+
244
+ def account(value: Any) -> Any:
245
+ """Mark a class as a program-local account layout."""
246
+ return value
247
+
248
+
249
+ def error(value: Any) -> Any:
250
+ """Mark a class as an on-chain error."""
251
+ return value
252
+
253
+
254
+ def enum(value: Any) -> Any:
255
+ """Mark a class as a one-byte unit enum (``Name = <declaration index>``)."""
256
+ return value
257
+
258
+
259
+ def struct(value: Any) -> Any:
260
+ """Mark a class as a fixed-layout value nested inside an account."""
261
+ return value
262
+
263
+
264
+ def event(value: Any) -> Any:
265
+ """Mark a class as a fixed-width value logged by ``emit``."""
266
+ return value
267
+
268
+
269
+ def token_transfer(src: object, dest: object, authority: object, amount: int) -> None:
270
+ """SPL Token Transfer (instruction 3) from ``src`` to ``dest``."""
271
+ del src, dest, authority, amount
272
+
273
+
274
+ def token2022_transfer(src: object, dest: object, authority: object, amount: int) -> None:
275
+ """Token-2022 Transfer (instruction 3). Same accounts as ``token_transfer``."""
276
+ del src, dest, authority, amount
277
+
278
+
279
+ def ata_create(payer: object, ata: object, owner: object, mint: object) -> None:
280
+ """Create an associated token account (idempotent). Uses the SPL Token program."""
281
+ del payer, ata, owner, mint
282
+
283
+
284
+ def system_transfer(src: object, dest: object, lamports: int) -> None:
285
+ """Move lamports with a System-program transfer CPI.
286
+
287
+ ``src`` and ``dest`` are a ``Signer`` or a ``Mut`` account, and they must
288
+ differ. The generated client appends the System program account.
289
+ """
290
+ del src, dest, lamports
291
+
292
+
293
+ def emit(value: object) -> None:
294
+ """Log one ``@event``. The compiler lowers the call; this stub does not.
295
+
296
+ Write ``emit(Name(field, ...))`` with one positional argument per field,
297
+ in declaration order. The on-chain log is the 8-byte event discriminator
298
+ (``sha256("event:Name")[..8]``) followed by the packed fields.
299
+ """
300
+ del value
301
+
302
+
303
+ class InstructionError(ProgramError):
304
+ """Recommended base for @error classes used via `require(cond, cls.name)`."""
305
+
306
+
307
+ def require(condition: bool, err: object) -> None:
308
+ """Fail the instruction immediately if the condition is false.
309
+
310
+ The compiler front-end reads calls to this helper statically; `err` must be
311
+ a member of an ``@error`` class (e.g. ``require(cond, MyErr.Bad)``). The
312
+ static check is what enforces that; the Python signature stays permissive
313
+ so the decorator surface type-checks with plain mypy/pyright.
314
+ """
315
+
316
+ if condition is not True: # pragma: no cover
317
+ raise err # type: ignore[misc] # DSL stub: real semantics live in the compiled C.
@@ -0,0 +1,7 @@
1
+ """Expose pythsol.cli (click command group)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pythsol.cli.main import cli, main
6
+
7
+ __all__ = ["cli", "main"]
pythsol/cli/cache.py ADDED
@@ -0,0 +1,78 @@
1
+ """Content-addressed cache for the SBF ``.so``.
2
+
3
+ The key is the generated C, the compiler version, the clang version, the
4
+ runtime header, and a flavor tag. A hit copies the ``.so`` and skips clang.
5
+ The C text is part of the key so an emitter change is a miss even when the
6
+ package version stays ``0.1.0``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from pythsol import __version__
16
+
17
+ __all__ = ["artifact_key", "cache_directory", "lookup_so", "store_so"]
18
+
19
+ #: Bump when compile or link flags change but the C text might not.
20
+ CACHE_FLAVOR = "sbf-o2-gc-strip-v1"
21
+
22
+
23
+ def cache_directory() -> Path | None:
24
+ """Return the cache root, or ``None`` when caching is off.
25
+
26
+ ``PYTHSOL_NO_CACHE=1`` disables it. ``PYTHSOL_CACHE`` (or ``PYSOL_CACHE``)
27
+ overrides the default directory.
28
+ """
29
+ flag = os.environ.get("PYTHSOL_NO_CACHE", "").strip().lower()
30
+ if flag in {"1", "true", "yes"}:
31
+ return None
32
+ override = os.environ.get("PYTHSOL_CACHE") or os.environ.get("PYSOL_CACHE")
33
+ if override:
34
+ return Path(override).expanduser()
35
+ local = os.environ.get("LOCALAPPDATA")
36
+ if local:
37
+ return Path(local) / "pythsol" / "cache"
38
+ return Path.home() / ".cache" / "pythsol"
39
+
40
+
41
+ def artifact_key(
42
+ *,
43
+ c_text: str,
44
+ clang_version: str,
45
+ arch: str,
46
+ runtime_header: bytes,
47
+ ) -> str:
48
+ """sha256 of everything that changes the linked ``.so``."""
49
+ digest = hashlib.sha256()
50
+ digest.update(b"pythsol-cache\0")
51
+ digest.update(CACHE_FLAVOR.encode())
52
+ digest.update(b"\0")
53
+ digest.update(__version__.encode())
54
+ digest.update(b"\0")
55
+ digest.update(clang_version.encode())
56
+ digest.update(b"\0")
57
+ digest.update(arch.encode())
58
+ digest.update(b"\0")
59
+ digest.update(hashlib.sha256(runtime_header).digest())
60
+ digest.update(c_text.encode())
61
+ return digest.hexdigest()
62
+
63
+
64
+ def lookup_so(root: Path, key: str) -> Path | None:
65
+ """Return a cached ``.so`` for ``key``, or ``None``."""
66
+ path = root / key / "program.so"
67
+ if path.is_file() and path.stat().st_size > 0:
68
+ return path
69
+ return None
70
+
71
+
72
+ def store_so(root: Path, key: str, so_path: Path) -> None:
73
+ """Copy ``so_path`` into the cache. A crash mid-write leaves the old file."""
74
+ dest_dir = root / key
75
+ dest_dir.mkdir(parents=True, exist_ok=True)
76
+ temporary = dest_dir / "program.so.tmp"
77
+ temporary.write_bytes(so_path.read_bytes())
78
+ temporary.replace(dest_dir / "program.so")
pythsol/cli/cluster.py ADDED
@@ -0,0 +1,45 @@
1
+ """Cluster names and RPC URLs shared by deploy, faucet, and wallet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ __all__ = ["DEVNET_URL", "is_mainnet", "resolve_url"]
8
+
9
+ DEVNET_URL = "https://api.devnet.solana.com"
10
+ MAINNET_URL = "https://api.mainnet-beta.solana.com"
11
+ LOCALHOST_URL = "http://127.0.0.1:8899"
12
+ TESTNET_URL = "https://api.testnet.solana.com"
13
+
14
+ _ALIASES = {
15
+ "devnet": DEVNET_URL,
16
+ "localhost": LOCALHOST_URL,
17
+ "localnet": LOCALHOST_URL,
18
+ "mainnet": MAINNET_URL,
19
+ "mainnet-beta": MAINNET_URL,
20
+ "testnet": TESTNET_URL,
21
+ }
22
+
23
+ _MAINNET = {
24
+ MAINNET_URL.rstrip("/").lower(),
25
+ "https://api.mainnet.solana.com",
26
+ }
27
+
28
+
29
+ def resolve_url(value: str | None) -> str:
30
+ """Turn a cluster name or URL into an RPC URL.
31
+
32
+ An empty value uses ``PYSOL_RPC_URL`` or ``PYTHSOL_RPC_URL``, then devnet.
33
+ """
34
+ if value is None or value.strip() == "":
35
+ env = os.environ.get("PYSOL_RPC_URL") or os.environ.get("PYTHSOL_RPC_URL")
36
+ if env:
37
+ return resolve_url(env)
38
+ return DEVNET_URL
39
+ key = value.strip()
40
+ return _ALIASES.get(key.lower(), key)
41
+
42
+
43
+ def is_mainnet(value: str) -> bool:
44
+ """True for mainnet aliases and the public mainnet RPC URL."""
45
+ return resolve_url(value).rstrip("/").lower() in _MAINNET
@@ -0,0 +1,214 @@
1
+ """`pythsol build PROGRAM.py` — C → SBF `.so` + IDL + manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from pythsol import __version__
15
+ from pythsol.cli.cache import artifact_key, cache_directory, lookup_so, store_so
16
+ from pythsol.client.gen import render_client
17
+ from pythsol.compiler.diagnostics import Diagnostic, PysolError
18
+ from pythsol.compiler.idl import idl_json
19
+ from pythsol.compiler.modules import read_sibling_modules
20
+ from pythsol.compiler.pipeline import emit_program
21
+ from pythsol.runtime import HEADER_NAME, RUNTIME_INCLUDE_DIR
22
+
23
+
24
+ class BuildFailed(Exception): # noqa: N818
25
+ """The program did not pass the compiler. ``diagnostics`` is non-empty."""
26
+
27
+ def __init__(self, diagnostics: list[Diagnostic]) -> None:
28
+ self.diagnostics = diagnostics
29
+ super().__init__(f"{len(diagnostics)} diagnostics")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BuiltProgram:
34
+ """Paths written by one successful build."""
35
+
36
+ name: str
37
+ workdir: Path
38
+ c_path: Path
39
+ so_path: Path
40
+ idl_path: Path
41
+ client_path: Path
42
+ manifest_path: Path
43
+ #: ``hit`` skipped clang, ``miss`` compiled, ``off`` cache disabled.
44
+ cache: str
45
+
46
+
47
+ def build_program(
48
+ program: Path,
49
+ out: Path | None = None,
50
+ name: str | None = None,
51
+ ) -> BuiltProgram:
52
+ """Compile ``program`` and return the artifact paths.
53
+
54
+ Raises :class:`BuildFailed` for diagnostics, :class:`OSError` when a
55
+ source file cannot be read, and :class:`PysolError` when clang is missing
56
+ or the link fails.
57
+ """
58
+ source_text = program.read_text(encoding="utf-8", errors="replace")
59
+ modules, module_files = read_sibling_modules(program)
60
+ generated = emit_program(
61
+ source_text,
62
+ program,
63
+ name=name,
64
+ modules=modules,
65
+ module_files=module_files,
66
+ )
67
+ if not generated.ok or generated.generated is None:
68
+ raise BuildFailed(list(generated.diagnostics))
69
+
70
+ workdir = (out or Path("target/pythsol") / generated.name).resolve()
71
+ workdir.mkdir(parents=True, exist_ok=True)
72
+
73
+ c_path = workdir / f"{generated.name}.c"
74
+ c_path.write_text(generated.c_text, encoding="utf-8", newline="\n")
75
+
76
+ from pythsol.compiler import toolchain
77
+
78
+ toolpaths = toolchain.locate_platform_tools(None)
79
+ if toolpaths is None:
80
+ raise PysolError(
81
+ code="PS4010",
82
+ message="no SBF platform-tools found; cannot finish the build",
83
+ fix="run `pythsol doctor`; set PYTHSOL_SBF_SDK or install Agave "
84
+ "platform-tools (CI Linux runners are the supported path)",
85
+ )
86
+
87
+ header_path = RUNTIME_INCLUDE_DIR / HEADER_NAME
88
+ header = header_path.read_bytes()
89
+ key = artifact_key(
90
+ c_text=generated.c_text,
91
+ clang_version=toolpaths.version or "unknown",
92
+ arch=toolpaths.arch,
93
+ runtime_header=header,
94
+ )
95
+ root = cache_directory()
96
+ cached = lookup_so(root, key) if root is not None else None
97
+ so_path = workdir / f"{generated.name}.so"
98
+ if cached is not None:
99
+ shutil.copyfile(cached, so_path)
100
+ cache = "hit"
101
+ else:
102
+ so_path = toolchain.compile_to_sbf(
103
+ toolpaths,
104
+ c_path,
105
+ workdir,
106
+ include_dirs=(RUNTIME_INCLUDE_DIR,),
107
+ )
108
+ if root is not None:
109
+ store_so(root, key, so_path)
110
+ cache = "miss" if root is not None else "off"
111
+
112
+ idl_path = workdir / f"{generated.name}.json"
113
+ idl_path.write_text(idl_json(generated.generated), encoding="utf-8", newline="\n")
114
+
115
+ client_path = workdir / f"{generated.name}_client.py"
116
+ client_path.write_text(
117
+ render_client(generated.generated),
118
+ encoding="utf-8",
119
+ newline="\n",
120
+ )
121
+
122
+ manifest = {
123
+ "generator": f"pythsol {__version__}",
124
+ "program": generated.name,
125
+ "source": generated.source_file,
126
+ "entrypoint": "entrypoint",
127
+ "cache": cache,
128
+ "files": {
129
+ "c": c_path.name,
130
+ "idl": idl_path.name,
131
+ "so": so_path.name,
132
+ "client": client_path.name,
133
+ },
134
+ "sha256": {
135
+ "c": _sha256(c_path),
136
+ "idl": _sha256(idl_path),
137
+ "so": _sha256(so_path),
138
+ "client": _sha256(client_path),
139
+ },
140
+ "toolchain": {
141
+ "arch": toolpaths.arch,
142
+ "clang": toolpaths.version or "unknown",
143
+ },
144
+ }
145
+ manifest_path = workdir / "manifest.json"
146
+ manifest_path.write_text(
147
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
148
+ encoding="utf-8",
149
+ newline="\n",
150
+ )
151
+ return BuiltProgram(
152
+ name=generated.name,
153
+ workdir=workdir,
154
+ c_path=c_path,
155
+ so_path=so_path,
156
+ idl_path=idl_path,
157
+ client_path=client_path,
158
+ manifest_path=manifest_path,
159
+ cache=cache,
160
+ )
161
+
162
+
163
+ @click.command(name="build")
164
+ @click.argument(
165
+ "program",
166
+ metavar="PROGRAM.py",
167
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
168
+ )
169
+ @click.option(
170
+ "--out",
171
+ type=click.Path(file_okay=False, path_type=Path),
172
+ default=None,
173
+ help="Output directory (default: target/pythsol/<name>).",
174
+ )
175
+ @click.option(
176
+ "--name",
177
+ default=None,
178
+ help="Program name (default: the file stem).",
179
+ )
180
+ def build(program: Path, out: Path | None, name: str | None) -> None:
181
+ """Compile a PythSol program to a native SBF `.so`, IDL and manifest.
182
+
183
+ Produces ``<name>.c`` (generated C), ``<name>.so`` (the on-chain ELF),
184
+ ``<name>.json`` (IDL), ``<name>_client.py`` (solders builders) and
185
+ ``manifest.json`` with sha256 hashes. Builds are byte-deterministic
186
+ for identical input. A second build of the same C skips clang when the
187
+ cache is on.
188
+ """
189
+ try:
190
+ built = build_program(program, out=out, name=name)
191
+ except OSError as os_error:
192
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
193
+ sys.exit(2)
194
+ except BuildFailed as failed:
195
+ for diagnostic in failed.diagnostics:
196
+ click.echo(diagnostic.render(), err=True)
197
+ sys.exit(1)
198
+ except PysolError as error:
199
+ click.echo(error.render(), err=True)
200
+ sys.exit(1)
201
+
202
+ click.echo(f"build: {program} -> {built.so_path}")
203
+ if built.cache == "hit":
204
+ click.echo("build: cache hit (skipped clang)")
205
+ elif built.cache == "miss":
206
+ click.echo("build: cache miss")
207
+ click.echo(f"build: idl -> {built.idl_path}")
208
+ click.echo(f"build: client -> {built.client_path}")
209
+ click.echo(f"build: manifest -> {built.manifest_path}")
210
+ sys.exit(0)
211
+
212
+
213
+ def _sha256(path: Path) -> str:
214
+ return hashlib.sha256(path.read_bytes()).hexdigest()