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,54 @@
1
+ """`pythsol check PROGRAM.py` — run the front-end and report diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from pythsol.compiler.front import check_source
12
+ from pythsol.compiler.modules import read_sibling_modules
13
+
14
+
15
+ @click.command(name="check")
16
+ @click.argument(
17
+ "program",
18
+ metavar="PROGRAM.py",
19
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
20
+ )
21
+ @click.option(
22
+ "--json",
23
+ "as_json",
24
+ is_flag=True,
25
+ default=False,
26
+ help="Emit machine-readable JSON diagnostics.",
27
+ )
28
+ def check(program: Path, as_json: bool) -> None:
29
+ """Type-check a program with the PythSol front-end (no SBF toolchain)."""
30
+ try:
31
+ source = program.read_text(encoding="utf-8", errors="replace")
32
+ modules, module_files = read_sibling_modules(program)
33
+ except OSError as err:
34
+ click.echo(f"error: cannot read {program}: {err}", err=True)
35
+ sys.exit(2)
36
+ diagnostics = check_source(
37
+ source,
38
+ file=str(program),
39
+ modules=modules,
40
+ module_files=module_files,
41
+ )
42
+ diagnostics.sort(key=lambda d: (d.file, d.line, d.col, d.code))
43
+ if as_json:
44
+ payload = {
45
+ "ok": not diagnostics,
46
+ "diagnostics": [d.as_dict() for d in diagnostics],
47
+ }
48
+ click.echo(json.dumps(payload, indent=2))
49
+ elif diagnostics:
50
+ for diag in diagnostics:
51
+ click.echo(diag.render())
52
+ else:
53
+ click.echo(f"check: {program} - no problems found")
54
+ sys.exit(0 if not diagnostics else 1)
@@ -0,0 +1,97 @@
1
+ """`pythsol client` and `pythsol cpi` — write a solders client.
2
+
3
+ A ``.py`` program is compiled with the front-end (no clang) and rendered from
4
+ the model. A ``.json`` IDL is rendered from the document. Neither output
5
+ imports the compiler.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from pythsol.client.from_idl import IdlClientError, render_client_from_idl
17
+ from pythsol.client.gen import render_client
18
+ from pythsol.compiler.modules import read_sibling_modules
19
+ from pythsol.compiler.pipeline import emit_program
20
+
21
+ _CPI_BANNER = (
22
+ "# Off-chain instruction builders for CPI into this program.\n"
23
+ "# PythSol does not lower a generic on-chain invoke. Call these from a client.\n"
24
+ )
25
+
26
+
27
+ def render_client_source(source: Path, *, address: str | None, cpi: bool) -> str:
28
+ """Return client source for a program or an IDL file."""
29
+ if source.suffix.lower() == ".json":
30
+ try:
31
+ payload = json.loads(source.read_text(encoding="utf-8"))
32
+ except json.JSONDecodeError as exc:
33
+ raise click.ClickException(f"invalid JSON: {exc}") from exc
34
+ if not isinstance(payload, dict):
35
+ raise click.ClickException("IDL JSON must be an object")
36
+ try:
37
+ text = render_client_from_idl(payload, address=address)
38
+ except IdlClientError as exc:
39
+ raise click.ClickException(str(exc)) from exc
40
+ else:
41
+ try:
42
+ source_text = source.read_text(encoding="utf-8", errors="replace")
43
+ modules, module_files = read_sibling_modules(source)
44
+ except OSError as exc:
45
+ raise click.ClickException(f"cannot read {source}: {exc}") from exc
46
+ generated = emit_program(
47
+ source_text,
48
+ source,
49
+ modules=modules,
50
+ module_files=module_files,
51
+ )
52
+ if not generated.ok or generated.generated is None:
53
+ for diagnostic in generated.diagnostics:
54
+ click.echo(diagnostic.render(), err=True)
55
+ raise SystemExit(1)
56
+ text = render_client(generated.generated)
57
+ if address:
58
+ needle = f"PROGRAM_NAME = {generated.generated.snake_name!r}\n"
59
+ insert = needle + f"PROGRAM_ID = {address!r}\n"
60
+ if needle in text:
61
+ text = text.replace(needle, insert, 1)
62
+ else:
63
+ text = f"PROGRAM_ID = {address!r}\n" + text
64
+ if cpi:
65
+ text = _CPI_BANNER + text
66
+ return text
67
+
68
+
69
+ def _write(text: str, out: Path | None) -> None:
70
+ if out is None:
71
+ click.echo(text, nl=False)
72
+ return
73
+ out.parent.mkdir(parents=True, exist_ok=True)
74
+ out.write_text(text, encoding="utf-8", newline="\n")
75
+ click.echo(f"wrote {out}")
76
+
77
+
78
+ @click.command(name="client")
79
+ @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
80
+ @click.option("--out", "-o", type=click.Path(dir_okay=False, path_type=Path), default=None)
81
+ @click.option("--address", default=None, help="Optional program id constant.")
82
+ def client_cmd(source: Path, out: Path | None, address: str | None) -> None:
83
+ """Generate a solders client from a program or an IDL JSON file."""
84
+ text = render_client_source(source, address=address, cpi=False)
85
+ _write(text, out)
86
+ sys.exit(0)
87
+
88
+
89
+ @click.command(name="cpi")
90
+ @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
91
+ @click.option("--out", "-o", type=click.Path(dir_okay=False, path_type=Path), default=None)
92
+ @click.option("--address", default=None, help="Optional program id constant.")
93
+ def cpi_cmd(source: Path, out: Path | None, address: str | None) -> None:
94
+ """Generate off-chain CPI builders from a program or an IDL JSON file."""
95
+ text = render_client_source(source, address=address, cpi=True)
96
+ _write(text, out)
97
+ sys.exit(0)
@@ -0,0 +1,103 @@
1
+ """`pythsol deploy` — build, then `solana program deploy`.
2
+
3
+ ``--dry-run`` prints the command and does not run it. Mainnet requires
4
+ ``--yes``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from pythsol.cli.cluster import is_mainnet, resolve_url
16
+ from pythsol.cli.commands.build import BuildFailed, build_program
17
+ from pythsol.compiler.diagnostics import PysolError
18
+ from pythsol.compiler.toolchain import default_solana_keypair
19
+
20
+ #: Lamports per program-data byte, the figure the TRD cites for a rent estimate.
21
+ _LAMPORTS_PER_BYTE = 6960
22
+
23
+
24
+ def deploy_argv(
25
+ so: Path,
26
+ url: str,
27
+ *,
28
+ keypair: Path | None,
29
+ program_keypair: Path | None,
30
+ ) -> list[str]:
31
+ """The ``solana program deploy`` invocation for this artifact."""
32
+ argv = ["solana", "program", "deploy", str(so), "--url", url]
33
+ if keypair is not None:
34
+ argv.extend(["--keypair", str(keypair)])
35
+ if program_keypair is not None:
36
+ argv.extend(["--program-id", str(program_keypair)])
37
+ return argv
38
+
39
+
40
+ @click.command(name="deploy")
41
+ @click.argument(
42
+ "program",
43
+ required=False,
44
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
45
+ )
46
+ @click.option("--so", "so_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
47
+ @click.option("--url", default=None, help="Cluster name or RPC URL. Default: devnet.")
48
+ @click.option("--keypair", type=click.Path(dir_okay=False, path_type=Path), default=None)
49
+ @click.option("--program-keypair", type=click.Path(dir_okay=False, path_type=Path), default=None)
50
+ @click.option("--dry-run", is_flag=True, help="Print the deploy command and do not run it.")
51
+ @click.option("--yes", is_flag=True, help="Required for mainnet.")
52
+ def deploy(
53
+ program: Path | None,
54
+ so_path: Path | None,
55
+ url: str | None,
56
+ keypair: Path | None,
57
+ program_keypair: Path | None,
58
+ dry_run: bool,
59
+ yes: bool,
60
+ ) -> None:
61
+ """Build (unless ``--so`` is set) and deploy with the Solana CLI."""
62
+ cluster = resolve_url(url)
63
+ if is_mainnet(cluster) and not yes:
64
+ click.echo("error: mainnet deploy requires --yes", err=True)
65
+ sys.exit(1)
66
+ if so_path is None:
67
+ if program is None:
68
+ click.echo("error: pass a program or --so", err=True)
69
+ sys.exit(2)
70
+ try:
71
+ built = build_program(program)
72
+ except OSError as os_error:
73
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
74
+ sys.exit(2)
75
+ except BuildFailed as failed:
76
+ for diagnostic in failed.diagnostics:
77
+ click.echo(diagnostic.render(), err=True)
78
+ sys.exit(1)
79
+ except PysolError as error:
80
+ click.echo(error.render(), err=True)
81
+ sys.exit(1)
82
+ so_path = built.so_path
83
+ size = so_path.stat().st_size
84
+ rent = size * _LAMPORTS_PER_BYTE
85
+ payer = keypair or default_solana_keypair()
86
+ argv = deploy_argv(so_path, cluster, keypair=payer, program_keypair=program_keypair)
87
+ click.echo(f"program bytes: {size}")
88
+ click.echo(f"estimated rent: {rent / 1_000_000_000:.6f} SOL (program data only)")
89
+ click.echo(" ".join(argv))
90
+ if dry_run:
91
+ sys.exit(0)
92
+ if shutil_which("solana") is None:
93
+ click.echo("error: solana CLI not found", err=True)
94
+ sys.exit(1)
95
+ completed = subprocess.run(argv, check=False)
96
+ sys.exit(int(completed.returncode))
97
+
98
+
99
+ def shutil_which(name: str) -> str | None:
100
+ """Indirection so tests can replace the lookup."""
101
+ import shutil
102
+
103
+ return shutil.which(name)
@@ -0,0 +1,62 @@
1
+ """`pythsol docs` — print the offline reference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import webbrowser
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ _REFERENCE = """\
12
+ PythSol offline reference
13
+
14
+ pythsol check PROGRAM.py type-check (no clang)
15
+ pythsol build PROGRAM.py native SBF .so, IDL, client, manifest
16
+ pythsol test build, then pytest on LiteSVM
17
+ pythsol init DIR --template counter|ledger|vault|escrow
18
+ pythsol idl PROGRAM.py IDL only
19
+ pythsol client PROGRAM.py -o client.py
20
+ pythsol client program.json -o client.py
21
+ pythsol port program.json -o sketch.py
22
+ pythsol deploy --dry-run print `solana program deploy` (mainnet needs --yes)
23
+ pythsol faucet --amount 1 devnet airdrop (mainnet refused)
24
+ pythsol wallet new --outfile id.json
25
+ pythsol verify --left a.so --right b.so
26
+ pythsol profile --logs run.log
27
+ pythsol idl-publish --address <ID> --dry-run
28
+ pythsol cpi PROGRAM.py -o cpi.py
29
+ pythsol serve local playground on 127.0.0.1:8765
30
+ check, emit, and compile (.so if clang is installed)
31
+
32
+ A second build of the same program prints `cache hit (skipped clang)`.
33
+ Tests use the fixtures svm, program, payer, accounts, and send.
34
+ Vec[T, N] is a fixed capacity. A data enum is `Name: u64` with the payload reserved.
35
+ Vec[T] without N is rejected. The hosted Pyodide playground is not in this
36
+ build; `pythsol serve` checks, emits, and compiles on your machine.
37
+ """
38
+
39
+
40
+ def _repo_doc() -> Path | None:
41
+ cwd = Path.cwd() / "docs" / "architecture.md"
42
+ if cwd.is_file():
43
+ return cwd
44
+ here = Path(__file__).resolve()
45
+ for parent in here.parents:
46
+ candidate = parent / "docs" / "architecture.md"
47
+ if candidate.is_file():
48
+ return candidate
49
+ return None
50
+
51
+
52
+ @click.command(name="docs")
53
+ @click.option("--open", "open_browser", is_flag=True, help="Open architecture.md if it is on disk.")
54
+ def docs(open_browser: bool) -> None:
55
+ """Print the offline command reference."""
56
+ click.echo(_REFERENCE, nl=False)
57
+ found = _repo_doc()
58
+ if found is not None:
59
+ click.echo(f"architecture: {found}")
60
+ if open_browser:
61
+ webbrowser.open(found.as_uri())
62
+ sys.exit(0)
@@ -0,0 +1,211 @@
1
+ """`pythsol doctor` — diagnose the local environment.
2
+
3
+ Checks are read-only and side-effect free. Private keys are never read or
4
+ printed: we only stat the keypair file to see whether it exists (TR-SEC-01).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.request import Request, urlopen # nosec B310 — only ever hits an opted-in RPC URL
16
+
17
+ import click
18
+
19
+ from pythsol.compiler import toolchain
20
+ from pythsol.compiler.diagnostics import PysolError
21
+
22
+ #: Default RPC endpoint; `doctor` only does a HEAD /
23
+ DEFAULT_RPC_URL = "https://api.devnet.solana.com"
24
+
25
+ #: Standard CLI fix command per missing check (TRD §7.2 doctor).
26
+ SOLANA_API_INSTALL_CMD = 'sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"'
27
+ PLATFORM_TOOLS_INSTALL_CMD = (
28
+ "solana-install update # installs Agave + platform-tools (clang/lld) under ~/.cache/solana"
29
+ )
30
+ KEYPAIR_GEN_CMD = "solana-keygen new --no-bip39-passphrase"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CheckResult:
35
+ """One recorded ✓ / ✗ line of `pythsol doctor`."""
36
+
37
+ name: str
38
+ ok: bool
39
+ detail: str
40
+ fix_cmd: str | None = None
41
+
42
+ def render(self) -> str:
43
+ mark = "✓" if self.ok else "✗"
44
+ body = f"{mark} {self.name}"
45
+ if self.detail:
46
+ body = f"{body} {self.detail}" if self.ok else f"{body} ({self.detail})"
47
+ if not self.ok and self.fix_cmd:
48
+ body = f"{body}\n → fix: {self.fix_cmd}"
49
+ return body
50
+
51
+ def to_json(self) -> dict[str, Any]:
52
+ return {"name": self.name, "ok": self.ok, "detail": self.detail, "fix_cmd": self.fix_cmd}
53
+
54
+
55
+ @dataclass
56
+ class DoctorReport:
57
+ checks: list[CheckResult] = field(default_factory=list)
58
+
59
+ def add(self, res: CheckResult) -> None:
60
+ self.checks.append(res)
61
+
62
+ @property
63
+ def ok(self) -> bool:
64
+ return all(r.ok for r in self.checks)
65
+
66
+ def render(self) -> str:
67
+ return "\n".join(r.render() for r in self.checks)
68
+
69
+ def to_json(self) -> dict[str, Any]:
70
+ return {"ok": self.ok, "checks": [r.to_json() for r in self.checks]}
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Individual checks (kept small so tests can stub each one)
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def check_python_version() -> CheckResult:
79
+ version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
80
+ ok = sys.version_info >= (3, 11) and sys.version_info < (3, 14)
81
+ fix = None if ok else "pipx install pythsol # needs Python 3.11-3.13"
82
+ return CheckResult("Python", ok, version, fix)
83
+
84
+
85
+ def check_platform_tools(sdk: str | None = None) -> CheckResult:
86
+ try:
87
+ tc = toolchain.locate_platform_tools(sdk)
88
+ except PysolError as err:
89
+ return CheckResult("platform-tools", False, err.message, PLATFORM_TOOLS_INSTALL_CMD)
90
+ if tc is None:
91
+ return CheckResult(
92
+ "platform-tools",
93
+ False,
94
+ "no Agave/Anza platform-tools with `sbf` clang found",
95
+ PLATFORM_TOOLS_INSTALL_CMD,
96
+ )
97
+ return CheckResult("platform-tools", True, tc.describe(), None)
98
+
99
+
100
+ def check_clang_lld(tc: toolchain.ToolchainPaths) -> CheckResult:
101
+ """Make sure the located clang/lld actually respond to `--version`."""
102
+ ok = _query_ok(str(tc.clang), ["--version"]) and _query_ok(str(tc.lld), ["--version"])
103
+ detail = "clang and ld.lld answer --version" if ok else "clang or ld.lld failed to run"
104
+ fix = None if ok else PLATFORM_TOOLS_INSTALL_CMD
105
+ return CheckResult("clang/lld sanity", ok, detail, fix)
106
+
107
+
108
+ def _query_ok(binary: str, args: list[str], timeout_s: float = 30.0) -> bool:
109
+ try:
110
+ proc = subprocess.run(
111
+ [binary, *args],
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ timeout=timeout_s,
116
+ )
117
+ except (OSError, subprocess.SubprocessError):
118
+ return False
119
+ return proc.returncode == 0
120
+
121
+
122
+ def check_solana_cli() -> CheckResult:
123
+ path = toolchain.find_solana_cli()
124
+ ok = path is not None
125
+ detail = f"solana at {path}" if ok else "not found on PATH"
126
+ return CheckResult("solana CLI", ok, detail, None if ok else SOLANA_API_INSTALL_CMD)
127
+
128
+
129
+ def check_cargo_build_sbf() -> CheckResult:
130
+ path = toolchain.find_cargo_build_sbf()
131
+ ok = path is not None
132
+ detail = f"cargo-build-sbf at {path}" if ok else "not found on PATH"
133
+ return CheckResult("cargo-build-sbf", ok, detail, None if ok else PLATFORM_TOOLS_INSTALL_CMD)
134
+
135
+
136
+ def check_keypair(path: str | None = None) -> CheckResult:
137
+ """The keypair file must exist; we never read or log its contents."""
138
+ p = toolchain.default_solana_keypair() if path is None else Path(path).expanduser()
139
+ ok = p.is_file()
140
+ detail = str(p) if ok else f"not found at {p}"
141
+ fix = None if ok else KEYPAIR_GEN_CMD
142
+ return CheckResult("solana keypair file", ok, detail, fix)
143
+
144
+
145
+ def check_rpc(
146
+ url: str | None = None,
147
+ *,
148
+ enabled: bool = True,
149
+ timeout_s: float = 5.0,
150
+ ) -> CheckResult:
151
+ """HEAD the RPC health endpoint. Never sends or stores a key."""
152
+ if not enabled:
153
+ return CheckResult("RPC reachable", True, "skipped (--no-rpc)", None)
154
+ target = (url or DEFAULT_RPC_URL).rstrip("/")
155
+ try:
156
+ with urlopen(
157
+ Request(target, method="HEAD", headers={"User-Agent": "pythsol-doctor/0.1"}),
158
+ timeout=timeout_s,
159
+ ) as resp:
160
+ status = getattr(resp, "status", resp.getcode())
161
+ ok = 200 <= int(status) < 500
162
+ detail = target
163
+ except Exception as err:
164
+ return CheckResult("RPC reachable", False, f"{target}: {err}", None)
165
+ return CheckResult("RPC reachable", ok, detail, None)
166
+
167
+
168
+ def run_doctor(
169
+ *,
170
+ rpc: str | None = None,
171
+ do_rpc: bool = True,
172
+ sdk: str | None = None,
173
+ ) -> DoctorReport:
174
+ """Build the full report (pure, no I/O beyond what is stated)."""
175
+ report = DoctorReport()
176
+ report.add(check_python_version())
177
+ tc_ok = False
178
+ try:
179
+ tc = toolchain.locate_platform_tools(sdk)
180
+ tc_ok = tc is not None and tc.clang.is_file()
181
+ except PysolError:
182
+ tc_ok = False
183
+ tc = None
184
+ report.add(check_platform_tools(sdk))
185
+ if tc_ok and tc is not None:
186
+ report.add(check_clang_lld(tc))
187
+ report.add(check_solana_cli())
188
+ report.add(check_cargo_build_sbf())
189
+ report.add(check_keypair())
190
+ report.add(check_rpc(rpc, enabled=do_rpc))
191
+ return report
192
+
193
+
194
+ @click.command(name="doctor")
195
+ @click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
196
+ @click.option("--rpc", default=None, metavar="URL", help="Override RPC URL.")
197
+ @click.option("--no-rpc", is_flag=True, default=False, help="Skip the RPC network probe.")
198
+ @click.option("--sdk", default=None, metavar="DIR", help="Override platform-tools discovery.")
199
+ def doctor(
200
+ as_json: bool,
201
+ rpc: str | None,
202
+ no_rpc: bool,
203
+ sdk: str | None,
204
+ ) -> None:
205
+ """Diagnose the local environment (toolchain, solana CLI, keypair, RPC)."""
206
+ report = run_doctor(rpc=rpc, do_rpc=not no_rpc, sdk=sdk)
207
+ if as_json:
208
+ click.echo(json.dumps(report.to_json(), indent=2))
209
+ else:
210
+ click.echo(report.render())
211
+ sys.exit(0 if report.ok else 1)
@@ -0,0 +1,69 @@
1
+ """`pythsol emit-c PROGRAM.py` — print or write the generated C."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from pythsol.compiler.modules import read_sibling_modules
12
+ from pythsol.compiler.pipeline import emit_program
13
+
14
+
15
+ @click.command(name="emit-c")
16
+ @click.argument(
17
+ "program",
18
+ metavar="PROGRAM.py",
19
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
20
+ )
21
+ @click.option(
22
+ "--out",
23
+ type=click.Path(dir_okay=False, writable=True, path_type=Path),
24
+ default=None,
25
+ help="Write the C file here instead of printing it.",
26
+ )
27
+ @click.option(
28
+ "--json",
29
+ "as_json",
30
+ is_flag=True,
31
+ default=False,
32
+ help="Emit machine-readable diagnostics on stdout.",
33
+ )
34
+ def emit_c(program: Path, out: Path | None, as_json: bool) -> None:
35
+ """Generate C from a PythSol program (no SBF toolchain needed)."""
36
+ try:
37
+ source_text = program.read_text(encoding="utf-8", errors="replace")
38
+ modules, module_files = read_sibling_modules(program)
39
+ except OSError as os_error:
40
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
41
+ sys.exit(2)
42
+
43
+ generated = emit_program(
44
+ source_text,
45
+ program,
46
+ modules=modules,
47
+ module_files=module_files,
48
+ )
49
+ if not generated.ok:
50
+ for diagnostic in generated.diagnostics:
51
+ click.echo(diagnostic.render(), err=True)
52
+ if as_json:
53
+ payload = {
54
+ "ok": False,
55
+ "diagnostics": [d.as_dict() for d in generated.diagnostics],
56
+ }
57
+ click.echo(json.dumps(payload, indent=2))
58
+ sys.exit(1)
59
+
60
+ if as_json:
61
+ click.echo(json.dumps({"ok": True, "diagnostics": []}, indent=2))
62
+
63
+ if out is None:
64
+ click.echo(generated.c_text)
65
+ else:
66
+ out.parent.mkdir(parents=True, exist_ok=True)
67
+ out.write_text(generated.c_text, encoding="utf-8", newline="\n")
68
+ click.echo(f"emit-c: wrote {out}")
69
+ sys.exit(0)
@@ -0,0 +1,66 @@
1
+ """`pythsol faucet` — request devnet SOL. Mainnet is refused."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from pythsol.cli.cluster import is_mainnet, resolve_url
11
+ from pythsol.cli.rpc import RpcError, post_json
12
+ from pythsol.compiler.toolchain import default_solana_keypair
13
+
14
+ _LAMPORTS = 1_000_000_000
15
+ _MAX_SOL = 5.0
16
+
17
+
18
+ def _pubkey_from_file(path: Path) -> str:
19
+ try:
20
+ from solders.keypair import Keypair
21
+ except ImportError:
22
+ click.echo(
23
+ "error: solders is required to read a keypair. pip install 'pythsol[test]'",
24
+ err=True,
25
+ )
26
+ sys.exit(1)
27
+ return str(Keypair.from_json(path.read_text(encoding="utf-8")).pubkey())
28
+
29
+
30
+ @click.command(name="faucet")
31
+ @click.option("--amount", default=1.0, show_default=True, help="SOL to request (max 5).")
32
+ @click.option("--url", default=None, help="Cluster name or RPC URL. Default: devnet.")
33
+ @click.option("--pubkey", default=None, help="Base58 address. Default: the local keypair.")
34
+ @click.option(
35
+ "--keypair",
36
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
37
+ default=None,
38
+ help="Keypair file whose address receives the airdrop.",
39
+ )
40
+ def faucet(amount: float, url: str | None, pubkey: str | None, keypair: Path | None) -> None:
41
+ """Request an airdrop. Devnet rate limits return a faucet link."""
42
+ cluster = resolve_url(url)
43
+ if is_mainnet(cluster):
44
+ click.echo("error: faucet refuses mainnet", err=True)
45
+ sys.exit(1)
46
+ if amount <= 0 or amount > _MAX_SOL:
47
+ click.echo(f"error: --amount must be between 0 and {_MAX_SOL:g} SOL", err=True)
48
+ sys.exit(2)
49
+ if pubkey is None:
50
+ path = keypair or default_solana_keypair()
51
+ if not path.is_file():
52
+ click.echo(f"error: keypair not found at {path}", err=True)
53
+ sys.exit(1)
54
+ pubkey = _pubkey_from_file(path)
55
+ lamports = round(amount * _LAMPORTS)
56
+ try:
57
+ body = post_json(cluster, "requestAirdrop", [pubkey, lamports])
58
+ except RpcError as exc:
59
+ message = str(exc)
60
+ click.echo(f"error: {message}", err=True)
61
+ if "rate" in message.lower() or "429" in message or "limit" in message.lower():
62
+ click.echo("Rate-limited. Use https://faucet.solana.com", err=True)
63
+ sys.exit(1)
64
+ signature = body.get("result")
65
+ click.echo(f"Airdropped {amount:g} SOL (sig {signature}).")
66
+ sys.exit(0)