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,69 @@
1
+ """`pythsol idl PROGRAM.py` — write the Anchor-style IDL (no SBF toolchain)."""
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.idl import idl_json
12
+ from pythsol.compiler.modules import read_sibling_modules
13
+ from pythsol.compiler.pipeline import emit_program
14
+
15
+
16
+ @click.command(name="idl")
17
+ @click.argument(
18
+ "program",
19
+ metavar="PROGRAM.py",
20
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
21
+ )
22
+ @click.option(
23
+ "--out",
24
+ "-o",
25
+ type=click.Path(dir_okay=False, writable=True, path_type=Path),
26
+ default=None,
27
+ help="Write the IDL here instead of printing it.",
28
+ )
29
+ @click.option(
30
+ "--json",
31
+ "as_json",
32
+ is_flag=True,
33
+ default=False,
34
+ help="Emit machine-readable diagnostics on stdout when the program fails.",
35
+ )
36
+ def idl(program: Path, out: Path | None, as_json: bool) -> None:
37
+ """Generate the legacy IDL for a PythSol program."""
38
+ try:
39
+ source_text = program.read_text(encoding="utf-8", errors="replace")
40
+ modules, module_files = read_sibling_modules(program)
41
+ except OSError as os_error:
42
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
43
+ sys.exit(2)
44
+
45
+ generated = emit_program(
46
+ source_text,
47
+ program,
48
+ modules=modules,
49
+ module_files=module_files,
50
+ )
51
+ if not generated.ok or generated.generated is None:
52
+ for diagnostic in generated.diagnostics:
53
+ click.echo(diagnostic.render(), err=True)
54
+ if as_json:
55
+ payload = {
56
+ "ok": False,
57
+ "diagnostics": [item.as_dict() for item in generated.diagnostics],
58
+ }
59
+ click.echo(json.dumps(payload, indent=2))
60
+ sys.exit(1)
61
+
62
+ text = idl_json(generated.generated)
63
+ if out is None:
64
+ click.echo(text, nl=False)
65
+ else:
66
+ out.parent.mkdir(parents=True, exist_ok=True)
67
+ out.write_text(text, encoding="utf-8", newline="\n")
68
+ click.echo(f"idl: wrote {out}")
69
+ sys.exit(0)
@@ -0,0 +1,80 @@
1
+ """`pythsol idl-publish` — write the IDL and optionally hand it to Anchor.
2
+
3
+ On-chain publish runs ``anchor idl init`` only when the Anchor CLI is on
4
+ ``PATH``. ``--dry-run`` prints that command and does not look for Anchor.
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 resolve_url
16
+ from pythsol.compiler.idl import idl_json
17
+ from pythsol.compiler.modules import read_sibling_modules
18
+ from pythsol.compiler.pipeline import emit_program
19
+
20
+
21
+ def anchor_idl_argv(idl_path: Path, address: str, url: str) -> list[str]:
22
+ """The Anchor CLI invocation that would publish this IDL."""
23
+ return [
24
+ "anchor",
25
+ "idl",
26
+ "init",
27
+ "--filepath",
28
+ str(idl_path),
29
+ "--provider.cluster",
30
+ url,
31
+ address,
32
+ ]
33
+
34
+
35
+ @click.command(name="idl-publish")
36
+ @click.argument("program", type=click.Path(exists=True, dir_okay=False, path_type=Path))
37
+ @click.option("--out", "-o", type=click.Path(dir_okay=False, path_type=Path), default=None)
38
+ @click.option("--address", required=True, help="Program id the IDL is published for.")
39
+ @click.option("--url", default=None, help="Cluster name or RPC URL. Default: devnet.")
40
+ @click.option("--dry-run", is_flag=True, help="Write the IDL and print the anchor command.")
41
+ def idl_publish(
42
+ program: Path,
43
+ out: Path | None,
44
+ address: str,
45
+ url: str | None,
46
+ dry_run: bool,
47
+ ) -> None:
48
+ """Write an IDL file. Publish it only when the Anchor CLI is installed."""
49
+ try:
50
+ source_text = program.read_text(encoding="utf-8", errors="replace")
51
+ modules, module_files = read_sibling_modules(program)
52
+ except OSError as os_error:
53
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
54
+ sys.exit(2)
55
+ generated = emit_program(
56
+ source_text,
57
+ program,
58
+ modules=modules,
59
+ module_files=module_files,
60
+ )
61
+ if not generated.ok or generated.generated is None:
62
+ for diagnostic in generated.diagnostics:
63
+ click.echo(diagnostic.render(), err=True)
64
+ sys.exit(1)
65
+ dest = out or Path("target/pythsol") / generated.name / f"{generated.name}.json"
66
+ dest.parent.mkdir(parents=True, exist_ok=True)
67
+ dest.write_text(idl_json(generated.generated, address=address), encoding="utf-8", newline="\n")
68
+ cluster = resolve_url(url)
69
+ argv = anchor_idl_argv(dest, address, cluster)
70
+ click.echo(f"idl: wrote {dest}")
71
+ click.echo(" ".join(argv))
72
+ if dry_run:
73
+ sys.exit(0)
74
+ import shutil
75
+
76
+ if shutil.which("anchor") is None:
77
+ click.echo("error: anchor CLI not found; IDL file was written", err=True)
78
+ sys.exit(1)
79
+ completed = subprocess.run(argv, check=False)
80
+ sys.exit(int(completed.returncode))
@@ -0,0 +1,65 @@
1
+ """`pythsol init DIR` — scaffold a counter, ledger, vault, or escrow program."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from pythsol.cli.templates import TEMPLATES, project_files
12
+
13
+ _NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
14
+
15
+
16
+ @click.command(name="init")
17
+ @click.argument(
18
+ "directory",
19
+ metavar="DIR",
20
+ type=click.Path(file_okay=False, path_type=Path),
21
+ )
22
+ @click.option(
23
+ "--template",
24
+ type=click.Choice(TEMPLATES),
25
+ default="counter",
26
+ show_default=True,
27
+ help="Program to write into programs/.",
28
+ )
29
+ @click.option(
30
+ "--name",
31
+ default=None,
32
+ help="Project name (default: the directory name).",
33
+ )
34
+ def init(directory: Path, template: str, name: str | None) -> None:
35
+ """Create a PythSol project (pysol.toml, program, README).
36
+
37
+ Refuses to write into a directory that already has files.
38
+ """
39
+ project = name or directory.name
40
+ if _NAME.fullmatch(project) is None:
41
+ click.echo(
42
+ f"error: name `{project}` must match {_NAME.pattern}",
43
+ err=True,
44
+ )
45
+ sys.exit(2)
46
+ if directory.exists() and not directory.is_dir():
47
+ click.echo(f"error: {directory} is not a directory", err=True)
48
+ sys.exit(2)
49
+ if directory.exists() and any(directory.iterdir()):
50
+ click.echo(f"error: {directory} is not empty", err=True)
51
+ sys.exit(2)
52
+
53
+ directory.mkdir(parents=True, exist_ok=True)
54
+ written: list[Path] = []
55
+ for relative, text in project_files(project, template).items():
56
+ path = directory / relative
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ path.write_text(text, encoding="utf-8", newline="\n")
59
+ written.append(path)
60
+
61
+ click.echo(f"Created {directory} (template: {template})")
62
+ for path in written:
63
+ click.echo(f" {path.relative_to(directory).as_posix()}")
64
+ click.echo(f"Next: cd {directory} && pythsol check programs/{project}.py && pythsol test")
65
+ sys.exit(0)
@@ -0,0 +1,42 @@
1
+ """`pythsol port` — sketch a PythSol program from an Anchor-legacy IDL."""
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.client.port import PortError, render_port
12
+
13
+
14
+ @click.command(name="port")
15
+ @click.argument("idl", type=click.Path(exists=True, dir_okay=False, path_type=Path))
16
+ @click.option("--out", "-o", type=click.Path(dir_okay=False, path_type=Path), default=None)
17
+ def port_cmd(idl: Path, out: Path | None) -> None:
18
+ """Write a skeleton program. Instruction bodies are ``pass``."""
19
+ try:
20
+ payload = json.loads(idl.read_text(encoding="utf-8"))
21
+ except json.JSONDecodeError as exc:
22
+ click.echo(f"error: invalid JSON: {exc}", err=True)
23
+ sys.exit(1)
24
+ except OSError as exc:
25
+ click.echo(f"error: cannot read {idl}: {exc}", err=True)
26
+ sys.exit(2)
27
+ if not isinstance(payload, dict):
28
+ click.echo("error: IDL JSON must be an object", err=True)
29
+ sys.exit(1)
30
+ try:
31
+ text = render_port(payload)
32
+ except PortError as exc:
33
+ click.echo(f"error: {exc}", err=True)
34
+ sys.exit(1)
35
+ if out is None:
36
+ click.echo(text, nl=False)
37
+ else:
38
+ out.parent.mkdir(parents=True, exist_ok=True)
39
+ out.write_text(text, encoding="utf-8", newline="\n")
40
+ click.echo(f"wrote {out}")
41
+ click.echo("TODO: replace each `pass` with the instruction body")
42
+ sys.exit(0)
@@ -0,0 +1,57 @@
1
+ """`pythsol profile` — read compute units and source locations from logs.
2
+
3
+ The runtime already logs ``(file.py:line)`` on failure and the loader logs
4
+ ``consumed N of M compute units`` for the whole instruction. This command
5
+ prints those. It does not invent a per-line CU split.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ _CU = re.compile(r"consumed (\d+) of (\d+)")
17
+ _LOC = re.compile(r"\(([^():]+\.py):(\d+)\)")
18
+
19
+
20
+ def profile_rows(text: str) -> list[tuple[str, str]]:
21
+ """``(location, detail)`` rows from program logs."""
22
+ rows: list[tuple[str, str]] = []
23
+ seen: set[tuple[str, str]] = set()
24
+ for line in text.splitlines():
25
+ for match in _LOC.finditer(line):
26
+ key = (f"{match.group(1)}:{match.group(2)}", "logged")
27
+ if key not in seen:
28
+ seen.add(key)
29
+ rows.append(key)
30
+ consumed = _CU.search(line)
31
+ if consumed:
32
+ rows.append(("(program)", f"{consumed.group(1)} of {consumed.group(2)} CU"))
33
+ return rows
34
+
35
+
36
+ @click.command(name="profile")
37
+ @click.option(
38
+ "--logs",
39
+ "logs_path",
40
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
41
+ default=None,
42
+ help="File of program logs. Default: stdin.",
43
+ )
44
+ def profile(logs_path: Path | None) -> None:
45
+ """Print source locations and the program compute-unit total from logs."""
46
+ if logs_path is None:
47
+ text = sys.stdin.read()
48
+ else:
49
+ text = logs_path.read_text(encoding="utf-8", errors="replace")
50
+ rows = profile_rows(text)
51
+ if not rows:
52
+ click.echo("profile: no source locations or compute-unit lines")
53
+ sys.exit(1)
54
+ click.echo(f"{'location':<24} detail")
55
+ for location, detail in rows:
56
+ click.echo(f"{location:<24} {detail}")
57
+ sys.exit(0)
@@ -0,0 +1,52 @@
1
+ """`pythsol serve` — local playground on 127.0.0.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from pythsol.playground.server import bind
10
+
11
+ _LOOPBACK = {"127.0.0.1", "localhost", "::1"}
12
+
13
+
14
+ @click.command(name="serve")
15
+ @click.option("--host", default="127.0.0.1", show_default=True)
16
+ @click.option("--port", default=8765, show_default=True, type=int)
17
+ @click.option(
18
+ "--public",
19
+ is_flag=True,
20
+ help="Allow a non-loopback host. Check and emit only; compile stays off.",
21
+ )
22
+ @click.option(
23
+ "--allow-compile",
24
+ is_flag=True,
25
+ help="With --public, also serve POST /v1/compile. Clang sees only "
26
+ "compiler-generated C, never visitor source. Off by default.",
27
+ )
28
+ def serve(host: str, port: int, public: bool, allow_compile: bool) -> None:
29
+ """Serve the playground. Check and emit parse source. They do not run it.
30
+
31
+ Without ``--public`` the process accepts only loopback and can compile.
32
+ ``--public`` is the hostable mode: any host, no clang until you also
33
+ pass ``--allow-compile``.
34
+ """
35
+ if host not in _LOOPBACK and not public:
36
+ click.echo(
37
+ "error: --host must be 127.0.0.1, localhost, or ::1 (or pass --public)",
38
+ err=True,
39
+ )
40
+ sys.exit(2)
41
+ try:
42
+ httpd = bind(host, port, public=public, allow_compile=allow_compile)
43
+ except OSError as exc:
44
+ click.echo(f"error: {exc}", err=True)
45
+ sys.exit(1)
46
+ mode = "check+emit+compile" if ((not public) or allow_compile) else "check+emit"
47
+ click.echo(f"playground: http://{host}:{port}/ ({mode})")
48
+ try:
49
+ httpd.serve_forever()
50
+ except KeyboardInterrupt:
51
+ httpd.shutdown()
52
+ sys.exit(0)
@@ -0,0 +1,217 @@
1
+ """`pythsol test` — build the program, then run pytest against LiteSVM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import shutil
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from pythsol.cli.commands.build import BuildFailed, build_program
17
+ from pythsol.cli.config import ConfigError, find_config, load_config
18
+ from pythsol.compiler.diagnostics import PysolError
19
+
20
+ _VALUE_FLAGS = {"-k", "-m", "--tb", "-p", "-c", "--override-ini", "-o", "--rootdir"}
21
+
22
+
23
+ def _has_node(args: tuple[str, ...]) -> bool:
24
+ """True when the user already named a file or directory for pytest."""
25
+ skip = False
26
+ for arg in args:
27
+ if skip:
28
+ skip = False
29
+ continue
30
+ if arg in _VALUE_FLAGS:
31
+ skip = True
32
+ continue
33
+ if arg.startswith("-"):
34
+ continue
35
+ return True
36
+ return False
37
+
38
+
39
+ def _project_root(program: Path) -> Path:
40
+ if program.parent.name == "programs":
41
+ return program.parent.parent
42
+ return program.parent
43
+
44
+
45
+ def _pytest_cmd(paths: list[str]) -> list[str]:
46
+ return [
47
+ sys.executable,
48
+ "-m",
49
+ "pytest",
50
+ "-p",
51
+ "pythsol.testing.plugin",
52
+ "-q",
53
+ "--tb=short",
54
+ *paths,
55
+ ]
56
+
57
+
58
+ def _wait_rpc(url: str, timeout_s: float) -> bool:
59
+ import time
60
+
61
+ from pythsol.cli.rpc import post_json
62
+
63
+ deadline = time.monotonic() + timeout_s
64
+ while time.monotonic() < deadline:
65
+ try:
66
+ post_json(url, "getHealth", [])
67
+ return True
68
+ except Exception:
69
+ time.sleep(0.4)
70
+ return False
71
+
72
+
73
+ def _free_port() -> int:
74
+ with socket.socket() as sock:
75
+ sock.bind(("127.0.0.1", 0))
76
+ return int(sock.getsockname()[1])
77
+
78
+
79
+ def _run_localnet(so: Path, cmd: list[str], env: dict[str, str], cwd: Path) -> int:
80
+ """Start a test validator, deploy ``so``, run pytest, then stop the validator."""
81
+ port = _free_port()
82
+ ledger = Path(tempfile.mkdtemp(prefix="pythsol-ledger-"))
83
+ url = f"http://127.0.0.1:{port}"
84
+ validator = [
85
+ "solana-test-validator",
86
+ "--ledger",
87
+ str(ledger),
88
+ "--rpc-port",
89
+ str(port),
90
+ "--bind-address",
91
+ "127.0.0.1",
92
+ "--reset",
93
+ "--quiet",
94
+ ]
95
+ proc = subprocess.Popen(validator, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
96
+ try:
97
+ if not _wait_rpc(url, 90):
98
+ click.echo("error: solana-test-validator did not open an RPC port", err=True)
99
+ return 1
100
+ deploy = ["solana", "program", "deploy", str(so), "--url", url]
101
+ completed = subprocess.run(deploy, capture_output=True, text=True, check=False)
102
+ if completed.returncode != 0:
103
+ click.echo(completed.stderr or completed.stdout or "deploy failed", err=True)
104
+ return 1
105
+ child_env = dict(env)
106
+ child_env["PYSOL_RPC_URL"] = url
107
+ child_env["PYTHSOL_TEST_RPC"] = url
108
+ click.echo(f"localnet: {url}")
109
+ result = subprocess.run(cmd, cwd=cwd, env=child_env, check=False)
110
+ return int(result.returncode)
111
+ finally:
112
+ proc.terminate()
113
+ try:
114
+ proc.wait(timeout=10)
115
+ except subprocess.TimeoutExpired:
116
+ proc.kill()
117
+ shutil.rmtree(ledger, ignore_errors=True)
118
+
119
+
120
+ @click.command(
121
+ name="test",
122
+ context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
123
+ )
124
+ @click.option(
125
+ "--program",
126
+ "program_path",
127
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
128
+ default=None,
129
+ help="Program entry. Default: the first [[programs]] entry in pysol.toml.",
130
+ )
131
+ @click.option("--localnet", is_flag=True, help="Also deploy onto solana-test-validator.")
132
+ @click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED)
133
+ def test_cmd(program_path: Path | None, localnet: bool, pytest_args: tuple[str, ...]) -> None:
134
+ """Build the program and run pytest.
135
+
136
+ Tests use the ``program``, ``payer``, ``svm``, ``accounts``, and ``send``
137
+ fixtures. The runner is LiteSVM. ``--localnet`` also starts
138
+ ``solana-test-validator`` and deploys the ``.so`` when that CLI is installed.
139
+ The fixtures stay on LiteSVM either way.
140
+ """
141
+ if localnet and (
142
+ shutil.which("solana-test-validator") is None or shutil.which("solana") is None
143
+ ):
144
+ click.echo("error: solana-test-validator not found", err=True)
145
+ click.echo(
146
+ ' fix: sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"',
147
+ err=True,
148
+ )
149
+ sys.exit(1)
150
+ if importlib.util.find_spec("pytest") is None:
151
+ click.echo("error: pytest is not installed. pip install 'pythsol[test]'", err=True)
152
+ sys.exit(1)
153
+
154
+ config_path = None if program_path is not None else find_config(Path.cwd())
155
+ out: Path | None = None
156
+ name: str | None = None
157
+ root: Path
158
+ if program_path is None:
159
+ if config_path is None:
160
+ click.echo("error: pass a program or run from a directory with pysol.toml", err=True)
161
+ sys.exit(2)
162
+ try:
163
+ config = load_config(config_path)
164
+ except ConfigError as exc:
165
+ click.echo(f"error: {exc}", err=True)
166
+ sys.exit(2)
167
+ entry = config.programs[0]
168
+ program_path = entry.entry
169
+ name = entry.name
170
+ out = config.out_dir / entry.name
171
+ root = config_path.parent
172
+ else:
173
+ root = _project_root(program_path)
174
+
175
+ if not program_path.is_file():
176
+ click.echo(f"error: program not found: {program_path}", err=True)
177
+ sys.exit(2)
178
+
179
+ try:
180
+ built = build_program(program_path, out=out, name=name)
181
+ except OSError as os_error:
182
+ click.echo(f"error: cannot read {program_path}: {os_error}", err=True)
183
+ sys.exit(2)
184
+ except BuildFailed as failed:
185
+ for diagnostic in failed.diagnostics:
186
+ click.echo(diagnostic.render(), err=True)
187
+ sys.exit(1)
188
+ except PysolError as error:
189
+ click.echo(error.render(), err=True)
190
+ sys.exit(1)
191
+
192
+ if built.cache == "hit":
193
+ click.echo("build: cache hit (skipped clang)")
194
+ click.echo(f"test: {built.so_path}")
195
+
196
+ if _has_node(pytest_args):
197
+ nodes = list(pytest_args)
198
+ else:
199
+ tests = root / "tests"
200
+ if not tests.is_dir():
201
+ click.echo(f"error: no tests directory at {tests}", err=True)
202
+ sys.exit(1)
203
+ nodes = [str(tests), *pytest_args]
204
+
205
+ env = os.environ.copy()
206
+ env["PYTHSOL_TEST_SO"] = str(built.so_path)
207
+ env["PYTHSOL_TEST_CLIENT"] = str(built.client_path)
208
+ env["PYTHSOL_TEST_SOURCE"] = str(program_path)
209
+ env["PYTHSOL_TEST_IDL"] = str(built.idl_path)
210
+ env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
211
+ cmd = _pytest_cmd(nodes)
212
+ if localnet:
213
+ code = _run_localnet(built.so_path, cmd, env, root)
214
+ else:
215
+ completed = subprocess.run(cmd, cwd=root, env=env, check=False)
216
+ code = int(completed.returncode)
217
+ sys.exit(code)
@@ -0,0 +1,96 @@
1
+ """`pythsol verify` — compare two program binaries, or a build against a dump.
2
+
3
+ Exit ``0`` when the sha256 matches. Exit ``3`` when it does not.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from pythsol.cli.cluster import resolve_url
17
+ from pythsol.cli.commands.build import BuildFailed, build_program
18
+ from pythsol.compiler.diagnostics import PysolError
19
+
20
+
21
+ def file_sha256(path: Path) -> str:
22
+ """sha256 hex of ``path``."""
23
+ return hashlib.sha256(path.read_bytes()).hexdigest()
24
+
25
+
26
+ def dump_argv(address: str, url: str, dest: Path) -> list[str]:
27
+ """``solana program dump`` invocation."""
28
+ return ["solana", "program", "dump", "--url", url, address, str(dest)]
29
+
30
+
31
+ def _emit(left: Path, right: Path) -> None:
32
+ left_hash = file_sha256(left)
33
+ right_hash = file_sha256(right)
34
+ if left_hash == right_hash:
35
+ click.echo(f"MATCH sha256:{left_hash}")
36
+ sys.exit(0)
37
+ click.echo(f"MISMATCH {left} sha256:{left_hash} ({left.stat().st_size} bytes)", err=True)
38
+ click.echo(f"MISMATCH {right} sha256:{right_hash} ({right.stat().st_size} bytes)", err=True)
39
+ sys.exit(3)
40
+
41
+
42
+ @click.command(name="verify")
43
+ @click.argument("program", required=False, type=click.Path(dir_okay=False, path_type=Path))
44
+ @click.option("--left", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None)
45
+ @click.option("--right", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None)
46
+ @click.option("--dump", "dump_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
47
+ @click.option("--address", default=None, help="On-chain program id to dump and compare.")
48
+ @click.option("--url", default=None, help="Cluster name or RPC URL for --address.")
49
+ def verify(
50
+ program: Path | None,
51
+ left: Path | None,
52
+ right: Path | None,
53
+ dump_path: Path | None,
54
+ address: str | None,
55
+ url: str | None,
56
+ ) -> None:
57
+ """Compare sha256 hashes. Exit 3 on a mismatch."""
58
+ if left is not None and right is not None:
59
+ _emit(left, right)
60
+ if left is not None or right is not None:
61
+ click.echo("error: pass both --left and --right", err=True)
62
+ sys.exit(2)
63
+ if program is None or not program.is_file():
64
+ click.echo("error: pass two files, or a program plus --dump or --address", err=True)
65
+ sys.exit(2)
66
+ try:
67
+ built = build_program(program)
68
+ except OSError as os_error:
69
+ click.echo(f"error: cannot read {program}: {os_error}", err=True)
70
+ sys.exit(2)
71
+ except BuildFailed as failed:
72
+ for diagnostic in failed.diagnostics:
73
+ click.echo(diagnostic.render(), err=True)
74
+ sys.exit(1)
75
+ except PysolError as error:
76
+ click.echo(error.render(), err=True)
77
+ sys.exit(1)
78
+ if dump_path is not None:
79
+ _emit(built.so_path, dump_path)
80
+ if address is None:
81
+ click.echo("error: pass --dump or --address to compare a build", err=True)
82
+ sys.exit(2)
83
+ import shutil
84
+
85
+ if shutil.which("solana") is None:
86
+ click.echo("error: solana CLI not found", err=True)
87
+ sys.exit(1)
88
+ with tempfile.TemporaryDirectory(prefix="pythsol-verify-") as tmp:
89
+ dest = Path(tmp) / "chain.so"
90
+ argv = dump_argv(address, resolve_url(url), dest)
91
+ click.echo(" ".join(argv))
92
+ completed = subprocess.run(argv, check=False)
93
+ if completed.returncode != 0 or not dest.is_file():
94
+ click.echo("error: solana program dump failed", err=True)
95
+ sys.exit(1)
96
+ _emit(built.so_path, dest)