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,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)
|