pyscriptc 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.
- pyscriptc/__init__.py +3 -0
- pyscriptc/__main__.py +3 -0
- pyscriptc/_posthog_token.py +1 -0
- pyscriptc/builtins.py +156 -0
- pyscriptc/canonical.py +167 -0
- pyscriptc/check.py +49 -0
- pyscriptc/cli.py +271 -0
- pyscriptc/coverage.py +388 -0
- pyscriptc/debug.py +120 -0
- pyscriptc/diagnostics.py +179 -0
- pyscriptc/errors.py +56 -0
- pyscriptc/ir.py +365 -0
- pyscriptc/llvm.py +2129 -0
- pyscriptc/log.py +127 -0
- pyscriptc/lower/__init__.py +1071 -0
- pyscriptc/lower/_calls.py +1499 -0
- pyscriptc/lower/_classes.py +969 -0
- pyscriptc/lower/_core.py +301 -0
- pyscriptc/lower/_expr.py +886 -0
- pyscriptc/lower/_stdlib.py +491 -0
- pyscriptc/lower/_stdlib_shared.py +93 -0
- pyscriptc/lower/_stmt.py +1173 -0
- pyscriptc/package.py +249 -0
- pyscriptc/restlower.py +1008 -0
- pyscriptc/runtime/ps_argv.c +28 -0
- pyscriptc/runtime/ps_b64.c +168 -0
- pyscriptc/runtime/ps_coll.c +133 -0
- pyscriptc/runtime/ps_dt.c +388 -0
- pyscriptc/runtime/ps_hash.c +411 -0
- pyscriptc/runtime/ps_help.c +219 -0
- pyscriptc/runtime/ps_hmac.c +120 -0
- pyscriptc/runtime/ps_io.c +198 -0
- pyscriptc/runtime/ps_island.c +272 -0
- pyscriptc/runtime/ps_json.c +402 -0
- pyscriptc/runtime/ps_mix.c +47 -0
- pyscriptc/runtime/ps_more.c +1089 -0
- pyscriptc/runtime/ps_osmore.c +447 -0
- pyscriptc/runtime/ps_path.c +547 -0
- pyscriptc/runtime/ps_re.c +1102 -0
- pyscriptc/runtime/ps_rt.c +1175 -0
- pyscriptc/runtime/ps_rt.h +189 -0
- pyscriptc/runtime/ps_struct.c +291 -0
- pyscriptc/runtime/ps_time.c +207 -0
- pyscriptc/runtime/ps_unicode.c +1521 -0
- pyscriptc/runtime/ps_url.c +327 -0
- pyscriptc/stdlib_queue.py +191 -0
- pyscriptc/stdmore.py +1596 -0
- pyscriptc/telemetry.py +219 -0
- pyscriptc-0.1.0.dist-info/METADATA +187 -0
- pyscriptc-0.1.0.dist-info/RECORD +54 -0
- pyscriptc-0.1.0.dist-info/WHEEL +5 -0
- pyscriptc-0.1.0.dist-info/entry_points.txt +2 -0
- pyscriptc-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyscriptc-0.1.0.dist-info/top_level.txt +1 -0
pyscriptc/__init__.py
ADDED
pyscriptc/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
TOKEN = "phc_yqbYPqN9SbrYLd4A3HKwoRFqzfe9Q5LiMbKg3QBrYKVc"
|
pyscriptc/builtins.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Closed builtin and method tables.
|
|
2
|
+
|
|
3
|
+
A name is lowered only when it is listed here. `len` of `str` and `len` of a
|
|
4
|
+
list are different operations; both are listed. Anything else stays a fence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Codes match ps_rt.c. Exception is the wildcard handler and a raise target.
|
|
12
|
+
EXCEPTIONS: dict[str, int] = {
|
|
13
|
+
"ValueError": 1,
|
|
14
|
+
"IndexError": 2,
|
|
15
|
+
"KeyError": 3,
|
|
16
|
+
"OverflowError": 4,
|
|
17
|
+
"ZeroDivisionError": 5,
|
|
18
|
+
"RuntimeError": 6,
|
|
19
|
+
"Exception": 7,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# User exception classes take codes from here up; matches PS_USER_BASE in
|
|
23
|
+
# ps_rt.h. Exception handlers treat every code at or above it as an
|
|
24
|
+
# Exception descendant, since all user exceptions inherit a lowered builtin.
|
|
25
|
+
USER_EXC_BASE = 8
|
|
26
|
+
|
|
27
|
+
# params are type kinds. return is a type kind, or "self" for the receiver's type.
|
|
28
|
+
STR_METHODS: dict[str, dict[str, Any]] = {
|
|
29
|
+
"strip": {"params": [], "return": "self"},
|
|
30
|
+
"lstrip": {"params": [], "return": "self"},
|
|
31
|
+
"rstrip": {"params": [], "return": "self"},
|
|
32
|
+
"upper": {"params": [], "return": "self"},
|
|
33
|
+
"lower": {"params": [], "return": "self"},
|
|
34
|
+
"startswith": {"params": ["self"], "return": "bool"},
|
|
35
|
+
"endswith": {"params": ["self"], "return": "bool"},
|
|
36
|
+
"find": {"params": ["self"], "return": "int"},
|
|
37
|
+
"replace": {"params": ["self", "self"], "return": "self"},
|
|
38
|
+
"count": {"params": ["self"], "return": "int"},
|
|
39
|
+
"index": {"params": ["self"], "return": "int"},
|
|
40
|
+
"rfind": {"params": ["self"], "return": "int"},
|
|
41
|
+
"isdigit": {"params": [], "return": "bool"},
|
|
42
|
+
"isalpha": {"params": [], "return": "bool"},
|
|
43
|
+
"isalnum": {"params": [], "return": "bool"},
|
|
44
|
+
"isspace": {"params": [], "return": "bool"},
|
|
45
|
+
"isupper": {"params": [], "return": "bool"},
|
|
46
|
+
"islower": {"params": [], "return": "bool"},
|
|
47
|
+
"title": {"params": [], "return": "self"},
|
|
48
|
+
"capitalize": {"params": [], "return": "self"},
|
|
49
|
+
# casefold is str-only in CPython; the "kinds" gate keeps bytes.casefold fenced.
|
|
50
|
+
"casefold": {"params": [], "return": "self", "kinds": {"str"}},
|
|
51
|
+
"swapcase": {"params": [], "return": "self"},
|
|
52
|
+
"zfill": {"params": ["int"], "return": "self"},
|
|
53
|
+
"removeprefix": {"params": ["self"], "return": "self"},
|
|
54
|
+
"removesuffix": {"params": ["self"], "return": "self"},
|
|
55
|
+
"splitlines": {"params": [], "return": "self_list"},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
LEN_KINDS = {"str", "bytes", "list", "dict"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _len_ok(typ: dict[str, Any]) -> bool:
|
|
62
|
+
if typ["kind"] in LEN_KINDS:
|
|
63
|
+
return True
|
|
64
|
+
return typ["kind"] == "class" and typ.get("name") == "Deque"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def builtin_spec(name: str, arg_types: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
68
|
+
"""Return the result type of a builtin call, or None when it is not lowered."""
|
|
69
|
+
|
|
70
|
+
if name == "len" and len(arg_types) == 1 and _len_ok(arg_types[0]):
|
|
71
|
+
return {"kind": "int"}
|
|
72
|
+
if name == "int" and len(arg_types) == 1 and arg_types[0]["kind"] in {"float", "bytes", "str"}:
|
|
73
|
+
return {"kind": "int"}
|
|
74
|
+
if name == "float" and len(arg_types) == 1 and arg_types[0]["kind"] in {"int", "str"}:
|
|
75
|
+
return {"kind": "float"}
|
|
76
|
+
if (
|
|
77
|
+
name == "str"
|
|
78
|
+
and len(arg_types) == 1
|
|
79
|
+
and arg_types[0]["kind"]
|
|
80
|
+
in {
|
|
81
|
+
"str",
|
|
82
|
+
"int",
|
|
83
|
+
"float",
|
|
84
|
+
"bool",
|
|
85
|
+
"bytes",
|
|
86
|
+
"none",
|
|
87
|
+
}
|
|
88
|
+
):
|
|
89
|
+
return {"kind": "str"}
|
|
90
|
+
if name == "str" and len(arg_types) == 1 and arg_types[0]["kind"] == "class" and arg_types[0].get("name") == "Path":
|
|
91
|
+
return {"kind": "str"}
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def method_spec(receiver: dict[str, Any], name: str, arg_types: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
96
|
+
if receiver["kind"] not in {"str", "bytes"}:
|
|
97
|
+
return None
|
|
98
|
+
if name == "encode":
|
|
99
|
+
# One optional str encoding argument; the value is checked in _call_attr.
|
|
100
|
+
if receiver["kind"] != "str" or len(arg_types) > 1:
|
|
101
|
+
return None
|
|
102
|
+
if arg_types and arg_types[0]["kind"] != "str":
|
|
103
|
+
return None
|
|
104
|
+
return {"kind": "bytes"}
|
|
105
|
+
if name == "decode":
|
|
106
|
+
if receiver["kind"] != "bytes" or len(arg_types) > 1:
|
|
107
|
+
return None
|
|
108
|
+
if arg_types and arg_types[0]["kind"] != "str":
|
|
109
|
+
return None
|
|
110
|
+
return {"kind": "str"}
|
|
111
|
+
if name in {"split", "rsplit"}:
|
|
112
|
+
# split([sep [, maxsplit]]); sep None is not a lowered type, so the
|
|
113
|
+
# whitespace form stays the 0-arg call.
|
|
114
|
+
if len(arg_types) > 2:
|
|
115
|
+
return None
|
|
116
|
+
if len(arg_types) >= 1 and arg_types[0]["kind"] != receiver["kind"]:
|
|
117
|
+
return None
|
|
118
|
+
if len(arg_types) == 2 and arg_types[1]["kind"] != "int":
|
|
119
|
+
return None
|
|
120
|
+
return {"kind": "list", "elem": {"kind": receiver["kind"]}}
|
|
121
|
+
if name in {"ljust", "rjust", "center"}:
|
|
122
|
+
if not 1 <= len(arg_types) <= 2:
|
|
123
|
+
return None
|
|
124
|
+
if arg_types[0]["kind"] != "int":
|
|
125
|
+
return None
|
|
126
|
+
if len(arg_types) == 2 and arg_types[1]["kind"] != receiver["kind"]:
|
|
127
|
+
return None
|
|
128
|
+
return {"kind": receiver["kind"]}
|
|
129
|
+
if name == "expandtabs":
|
|
130
|
+
if len(arg_types) > 1 or (arg_types and arg_types[0]["kind"] != "int"):
|
|
131
|
+
return None
|
|
132
|
+
return {"kind": receiver["kind"]}
|
|
133
|
+
if name == "join":
|
|
134
|
+
if len(arg_types) != 1 or arg_types[0]["kind"] != "list":
|
|
135
|
+
return None
|
|
136
|
+
if arg_types[0]["elem"]["kind"] != receiver["kind"]:
|
|
137
|
+
return None
|
|
138
|
+
return {"kind": receiver["kind"]}
|
|
139
|
+
if name not in STR_METHODS:
|
|
140
|
+
return None
|
|
141
|
+
spec = STR_METHODS[name]
|
|
142
|
+
if receiver["kind"] not in spec.get("kinds", {"str", "bytes"}):
|
|
143
|
+
return None
|
|
144
|
+
if len(arg_types) != len(spec["params"]):
|
|
145
|
+
return None
|
|
146
|
+
for got, want in zip(arg_types, spec["params"], strict=True):
|
|
147
|
+
if want == "self":
|
|
148
|
+
if got["kind"] != receiver["kind"]:
|
|
149
|
+
return None
|
|
150
|
+
elif got["kind"] != want:
|
|
151
|
+
return None
|
|
152
|
+
if spec["return"] == "self":
|
|
153
|
+
return {"kind": receiver["kind"]}
|
|
154
|
+
if spec["return"] == "self_list":
|
|
155
|
+
return {"kind": "list", "elem": {"kind": receiver["kind"]}}
|
|
156
|
+
return {"kind": spec["return"]}
|
pyscriptc/canonical.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Canonical form of the JSON IR and its stable hash.
|
|
2
|
+
|
|
3
|
+
Probes compare compiler runs across invocations, so the IR they digest must
|
|
4
|
+
not carry anything incidental: spans name the invocation directory, the
|
|
5
|
+
cpython stamp tracks the interpreter rather than the compiler, and package
|
|
6
|
+
merge notes are stderr material. The rules and the full key vocabulary live
|
|
7
|
+
in docs/ir-canonicity.md; this module is that document in code.
|
|
8
|
+
|
|
9
|
+
The hash is schema-versioned (the sccache FORMAT_VERSION pattern): a change
|
|
10
|
+
to the canonicalization rules rekeys every digest instead of masquerading as
|
|
11
|
+
a compiler change.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
CANONICAL_SCHEMA_VERSION = "canonical-schema/2"
|
|
26
|
+
|
|
27
|
+
# Schema/2: environment-derived absolute paths reaching IR string constants
|
|
28
|
+
# (stdmore.py reads sys.prefix / sys.executable at compile time) are replaced
|
|
29
|
+
# with this placeholder so the digest cannot depend on the checkout or venv
|
|
30
|
+
# location. Decision 2026-10-16.
|
|
31
|
+
_PATH_PLACEHOLDER = "<PATH>"
|
|
32
|
+
|
|
33
|
+
# Keys removed at every nesting depth. `span` rides on functions, classes,
|
|
34
|
+
# statements, expressions and fence records; the rest are module level only.
|
|
35
|
+
_STRIP_KEYS = frozenset({"span", "sourceFile", "cpython", "_notes"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def canonicalize(module: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
"""Return a canonical deep copy: incidental keys stripped, orders normalized.
|
|
40
|
+
|
|
41
|
+
- every `span`, plus top-level `sourceFile`, `cpython`, `_notes`, gone;
|
|
42
|
+
- every substring of an environment absolute path (sys.executable,
|
|
43
|
+
sys._base_executable, sys.exec_prefix, sys.prefix, cwd, the pyscriptc
|
|
44
|
+
checkout root) in any string value is replaced by `<PATH>`; longest
|
|
45
|
+
first so an executable path is consumed before its prefix runs;
|
|
46
|
+
- `classes` sorted by `name` (synthetic class order leaks a set iteration
|
|
47
|
+
order at lower.py:607);
|
|
48
|
+
- every try-handler's `codes` list sorted (same leak via self.classes);
|
|
49
|
+
- `fences` sorted by (code, message, function or "", span.line, span.col),
|
|
50
|
+
computed before spans are stripped because `function` may be None.
|
|
51
|
+
|
|
52
|
+
Idempotent: canonicalize(canonicalize(x)) == canonicalize(x). The input
|
|
53
|
+
dict is never mutated.
|
|
54
|
+
"""
|
|
55
|
+
out = _strip(module)
|
|
56
|
+
classes = out.get("classes")
|
|
57
|
+
if isinstance(classes, list):
|
|
58
|
+
out["classes"] = sorted(classes, key=lambda cls: cls.get("name") or "")
|
|
59
|
+
for stmt in _walk_stmts(out):
|
|
60
|
+
for handler in stmt.get("handlers", ()):
|
|
61
|
+
if isinstance(handler, dict) and isinstance(handler.get("codes"), list):
|
|
62
|
+
handler["codes"] = sorted(handler["codes"])
|
|
63
|
+
fences = out.get("fences")
|
|
64
|
+
if isinstance(fences, list):
|
|
65
|
+
out["fences"] = sorted(
|
|
66
|
+
fences,
|
|
67
|
+
key=lambda fence: (
|
|
68
|
+
fence.get("code") or "",
|
|
69
|
+
fence.get("message") or "",
|
|
70
|
+
fence.get("function") or "",
|
|
71
|
+
fence.get("span", {}).get("line", 0),
|
|
72
|
+
fence.get("span", {}).get("col", 0),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
return _scrub_paths(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hash_module(module: dict[str, Any]) -> str:
|
|
79
|
+
"""SHA-256 hex of the schema-versioned canonical form of a module IR dict."""
|
|
80
|
+
canonical = {
|
|
81
|
+
"schema": CANONICAL_SCHEMA_VERSION,
|
|
82
|
+
"ir": canonicalize(module),
|
|
83
|
+
}
|
|
84
|
+
# allow_nan=False on purpose: a NaN reaching IR means a compiler bug and
|
|
85
|
+
# should surface here instead of silently hashing into a digest.
|
|
86
|
+
payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
|
87
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@lru_cache(maxsize=1)
|
|
91
|
+
def _env_paths() -> tuple[str, ...]:
|
|
92
|
+
"""Every absolute path this environment could leak into IR strings.
|
|
93
|
+
|
|
94
|
+
Realpath and as-given variants both (macOS /tmp is a symlink), longest
|
|
95
|
+
first so a path nested under another is replaced before its container.
|
|
96
|
+
Cached: sys.prefix etc. cannot change inside one process.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
roots: list[str] = []
|
|
100
|
+
for raw in (
|
|
101
|
+
getattr(sys, "executable", ""),
|
|
102
|
+
getattr(sys, "_base_executable", ""),
|
|
103
|
+
getattr(sys, "exec_prefix", ""),
|
|
104
|
+
getattr(sys, "prefix", ""),
|
|
105
|
+
os.getcwd(),
|
|
106
|
+
_checkout_root(),
|
|
107
|
+
):
|
|
108
|
+
if not raw:
|
|
109
|
+
continue
|
|
110
|
+
roots.append(os.path.normpath(raw))
|
|
111
|
+
real = os.path.realpath(raw)
|
|
112
|
+
if real != roots[-1]:
|
|
113
|
+
roots.append(real)
|
|
114
|
+
# Drop paths contained in a longer candidate: replacing the container
|
|
115
|
+
# first would strand the remainder of the shorter one as plain text.
|
|
116
|
+
return tuple(sorted(set(roots), key=len, reverse=True))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _checkout_root() -> str:
|
|
120
|
+
"""Repository root found by walking up from this file to a pyproject.toml."""
|
|
121
|
+
|
|
122
|
+
for parent in Path(__file__).resolve().parents:
|
|
123
|
+
if (parent / "pyproject.toml").is_file():
|
|
124
|
+
return str(parent)
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _scrub_paths(value: Any) -> Any:
|
|
129
|
+
"""Replace environment path substrings in every string value (keys are
|
|
130
|
+
structural: the IR key vocabulary is fixed and never carries paths)."""
|
|
131
|
+
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
return {k: _scrub_paths(v) for k, v in value.items()}
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
return [_scrub_paths(item) for item in value]
|
|
136
|
+
if isinstance(value, str):
|
|
137
|
+
for path in _env_paths():
|
|
138
|
+
if path in value:
|
|
139
|
+
value = value.replace(path, _PATH_PLACEHOLDER)
|
|
140
|
+
return value
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _strip(value: Any) -> Any:
|
|
145
|
+
"""Deep copy `value` dropping every _STRIP_KEYS member at any depth."""
|
|
146
|
+
if isinstance(value, dict):
|
|
147
|
+
return {k: _strip(v) for k, v in value.items() if k not in _STRIP_KEYS}
|
|
148
|
+
if isinstance(value, list):
|
|
149
|
+
return [_strip(item) for item in value]
|
|
150
|
+
if isinstance(value, tuple):
|
|
151
|
+
return [_strip(item) for item in value]
|
|
152
|
+
return copy.copy(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _walk_stmts(value: Any):
|
|
156
|
+
"""Yield every dict that looks like a statement node, recursively."""
|
|
157
|
+
if isinstance(value, dict):
|
|
158
|
+
if isinstance(value.get("kind"), str) and "handlers" in value:
|
|
159
|
+
yield value
|
|
160
|
+
for handler in value["handlers"]:
|
|
161
|
+
yield from _walk_stmts(handler.get("body"))
|
|
162
|
+
else:
|
|
163
|
+
for child in value.values():
|
|
164
|
+
yield from _walk_stmts(child)
|
|
165
|
+
elif isinstance(value, (list, tuple)):
|
|
166
|
+
for item in value:
|
|
167
|
+
yield from _walk_stmts(item)
|
pyscriptc/check.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Checker preflight.
|
|
2
|
+
|
|
3
|
+
mypy is a subprocess. Its AST is not an input to lowering. A bad annotation
|
|
4
|
+
fails the build before LLVM emission.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CheckFailed(Exception):
|
|
16
|
+
def __init__(self, output: str) -> None:
|
|
17
|
+
super().__init__(output)
|
|
18
|
+
self.output = output
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_checker(path: Path, checker: str = "mypy") -> None:
|
|
22
|
+
if checker != "mypy":
|
|
23
|
+
raise CheckFailed(f"checker {checker!r} is not wired; the spike preflight uses mypy")
|
|
24
|
+
with tempfile.TemporaryDirectory(prefix="pyscriptc-mypy-") as cache:
|
|
25
|
+
proc = subprocess.run(
|
|
26
|
+
[
|
|
27
|
+
sys.executable,
|
|
28
|
+
"-m",
|
|
29
|
+
"mypy",
|
|
30
|
+
"--show-error-codes",
|
|
31
|
+
"--hide-error-context",
|
|
32
|
+
"--no-error-summary",
|
|
33
|
+
"--follow-imports=silent",
|
|
34
|
+
"--cache-dir",
|
|
35
|
+
cache,
|
|
36
|
+
"--no-incremental",
|
|
37
|
+
str(path),
|
|
38
|
+
],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
)
|
|
42
|
+
if proc.returncode == 0:
|
|
43
|
+
return
|
|
44
|
+
output = (proc.stdout or proc.stderr).strip()
|
|
45
|
+
if not output:
|
|
46
|
+
output = proc.stderr.strip() or f"mypy exited {proc.returncode}"
|
|
47
|
+
if "No module named mypy" in output:
|
|
48
|
+
output = "mypy is not installed for this interpreter"
|
|
49
|
+
raise CheckFailed(output)
|
pyscriptc/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Command line for the spike compiler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ast
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pyscriptc import telemetry
|
|
15
|
+
from pyscriptc.check import CheckFailed, run_checker
|
|
16
|
+
from pyscriptc.coverage import build_report, render_text
|
|
17
|
+
from pyscriptc.debug import Debug, resolve
|
|
18
|
+
from pyscriptc.diagnostics import explain
|
|
19
|
+
from pyscriptc.errors import FitError, IRError, SpikeError, VersionError
|
|
20
|
+
from pyscriptc.ir import validate_module
|
|
21
|
+
from pyscriptc.llvm import emit_llvm, link_binary
|
|
22
|
+
from pyscriptc.log import enable_debug, get_logger, tail
|
|
23
|
+
from pyscriptc.lower import lower_file, render_fences
|
|
24
|
+
from pyscriptc.package import lower_directory
|
|
25
|
+
|
|
26
|
+
log = get_logger()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="pyscriptc",
|
|
32
|
+
description="Lower a typed Python subset to IR and LLVM.",
|
|
33
|
+
)
|
|
34
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
35
|
+
common.add_argument("-v", "--verbose", action="store_true", help="enable DEBUG audit records")
|
|
36
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
37
|
+
compile_parser = sub.add_parser("compile", help="lower a source file", parents=[common])
|
|
38
|
+
compile_parser.add_argument("source")
|
|
39
|
+
compile_parser.add_argument("--emit", choices=("ir", "llvm", "bin"), default="llvm")
|
|
40
|
+
compile_parser.add_argument("-o", dest="output")
|
|
41
|
+
compile_parser.add_argument("--call", help="function the generated main calls")
|
|
42
|
+
compile_parser.add_argument(
|
|
43
|
+
"--args",
|
|
44
|
+
default="[]",
|
|
45
|
+
help="Python literal list of arguments for --call, parsed with ast.literal_eval",
|
|
46
|
+
)
|
|
47
|
+
compile_parser.add_argument("--run", action="store_true", help="compile a binary and run it")
|
|
48
|
+
compile_parser.add_argument(
|
|
49
|
+
"--driver",
|
|
50
|
+
choices=("clang", "zig"),
|
|
51
|
+
default="clang",
|
|
52
|
+
help="C toolchain driver; zig means `zig cc` (required for --target)",
|
|
53
|
+
)
|
|
54
|
+
compile_parser.add_argument(
|
|
55
|
+
"--target",
|
|
56
|
+
help="cross-compilation triple for `zig cc`, e.g. aarch64-linux-gnu.2.39",
|
|
57
|
+
)
|
|
58
|
+
compile_parser.add_argument("--skip-check", action="store_true")
|
|
59
|
+
compile_parser.add_argument("--checker", default="mypy")
|
|
60
|
+
compile_parser.add_argument(
|
|
61
|
+
"--debug",
|
|
62
|
+
nargs="?",
|
|
63
|
+
const="",
|
|
64
|
+
default=None,
|
|
65
|
+
metavar="WHAT",
|
|
66
|
+
help="print a compact stage table to stderr; WHAT is a comma list of "
|
|
67
|
+
"stages,fences,cc,paths,all (also via PYSC_DEBUG)",
|
|
68
|
+
)
|
|
69
|
+
compile_parser.add_argument(
|
|
70
|
+
"--debug-dir",
|
|
71
|
+
metavar="DIR",
|
|
72
|
+
help="write debug artifacts (ir.json, out.ll, cc.sh, summary.json) to DIR",
|
|
73
|
+
)
|
|
74
|
+
coverage_parser = sub.add_parser("coverage", help="report every fence in a file or package tree", parents=[common])
|
|
75
|
+
coverage_parser.add_argument("source")
|
|
76
|
+
coverage_parser.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
77
|
+
explain_parser = sub.add_parser("explain", help="describe a diagnostic code", parents=[common])
|
|
78
|
+
explain_parser.add_argument("code")
|
|
79
|
+
sub.add_parser("logs", help="print the audit-log path and its last lines", parents=[common])
|
|
80
|
+
telemetry_parser = sub.add_parser("telemetry", help="anonymous usage metrics opt-out")
|
|
81
|
+
telemetry_parser.add_argument("action", choices=("disable", "enable", "status"))
|
|
82
|
+
try:
|
|
83
|
+
args = parser.parse_args(argv)
|
|
84
|
+
if getattr(args, "verbose", False):
|
|
85
|
+
enable_debug()
|
|
86
|
+
log.info("cli.invoke", cmd=args.cmd)
|
|
87
|
+
telemetry.maybe_print_notice()
|
|
88
|
+
telemetry.track("cli.invoked", {"subcommand": args.cmd, **telemetry.base_properties()})
|
|
89
|
+
if args.cmd == "compile":
|
|
90
|
+
return compile_cmd(args)
|
|
91
|
+
if args.cmd == "coverage":
|
|
92
|
+
return coverage_cmd(args)
|
|
93
|
+
if args.cmd == "explain":
|
|
94
|
+
return explain_cmd(args)
|
|
95
|
+
if args.cmd == "logs":
|
|
96
|
+
return logs_cmd(args)
|
|
97
|
+
if args.cmd == "telemetry":
|
|
98
|
+
return telemetry.telemetry_cmd(args.action)
|
|
99
|
+
except CheckFailed as exc:
|
|
100
|
+
print(str(exc), file=sys.stderr)
|
|
101
|
+
return 2
|
|
102
|
+
except (VersionError, FitError) as exc:
|
|
103
|
+
print(str(exc), file=sys.stderr)
|
|
104
|
+
return 4
|
|
105
|
+
except SpikeError as exc:
|
|
106
|
+
print(str(exc), file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def coverage_cmd(args: argparse.Namespace) -> int:
|
|
112
|
+
report = build_report(Path(args.source))
|
|
113
|
+
log.info("coverage.report", source=args.source)
|
|
114
|
+
print(json.dumps(report, indent=2) if args.json else render_text(report))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def explain_cmd(args: argparse.Namespace) -> int:
|
|
119
|
+
text = explain(args.code)
|
|
120
|
+
log.info("explain.lookup", code=args.code, known=text is not None)
|
|
121
|
+
if text is None:
|
|
122
|
+
print(f"unknown diagnostic code: {args.code}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
print(text)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def logs_cmd(args: argparse.Namespace) -> int:
|
|
129
|
+
tail()
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compile_cmd(args: argparse.Namespace) -> int:
|
|
134
|
+
started = time.monotonic()
|
|
135
|
+
state: dict[str, Any] = {"ok": False, "fence_code": None, "ran_bytes": None}
|
|
136
|
+
try:
|
|
137
|
+
rc = _compile_cmd(args, state)
|
|
138
|
+
state["ok"] = rc == 0
|
|
139
|
+
return rc
|
|
140
|
+
finally:
|
|
141
|
+
properties: dict[str, Any] = {
|
|
142
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
143
|
+
"ok": state["ok"],
|
|
144
|
+
}
|
|
145
|
+
if state["fence_code"]:
|
|
146
|
+
properties["fence_code"] = state["fence_code"]
|
|
147
|
+
telemetry.track("compile.finished", properties)
|
|
148
|
+
if state["ran_bytes"] is not None:
|
|
149
|
+
telemetry.track("compile.ran", {"binary_bytes": state["ran_bytes"]})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _compile_cmd(args: argparse.Namespace, state: dict[str, Any]) -> int:
|
|
153
|
+
try:
|
|
154
|
+
dbg = resolve(args.debug, args.debug_dir)
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
raise IRError(str(exc)) from exc
|
|
157
|
+
if dbg is None:
|
|
158
|
+
dbg = Debug(set(), None)
|
|
159
|
+
source_path = Path(args.source)
|
|
160
|
+
log.info(
|
|
161
|
+
"compile.start",
|
|
162
|
+
source=str(source_path),
|
|
163
|
+
emit=args.emit,
|
|
164
|
+
run=args.run,
|
|
165
|
+
driver=args.driver,
|
|
166
|
+
target=args.target,
|
|
167
|
+
checker=None if args.skip_check else args.checker,
|
|
168
|
+
)
|
|
169
|
+
module: dict[str, Any] | None = None
|
|
170
|
+
llvm_text: str | None = None
|
|
171
|
+
outputs: dict[str, str] = {}
|
|
172
|
+
status = "ok"
|
|
173
|
+
try:
|
|
174
|
+
dbg.begin("lower")
|
|
175
|
+
if source_path.is_dir():
|
|
176
|
+
module, source = lower_directory(source_path)
|
|
177
|
+
elif source_path.is_file():
|
|
178
|
+
module, source = lower_file(str(source_path))
|
|
179
|
+
else:
|
|
180
|
+
raise IRError(f"source not found: {source_path}")
|
|
181
|
+
dbg.end("lower")
|
|
182
|
+
if args.run:
|
|
183
|
+
args.emit = "bin"
|
|
184
|
+
for note in module.pop("_notes", []):
|
|
185
|
+
print(note, file=sys.stderr)
|
|
186
|
+
if not args.skip_check:
|
|
187
|
+
dbg.begin("check")
|
|
188
|
+
run_checker(source_path, args.checker)
|
|
189
|
+
dbg.end("check")
|
|
190
|
+
dbg.begin("validate")
|
|
191
|
+
validate_module(module)
|
|
192
|
+
dbg.end("validate")
|
|
193
|
+
ir_path = _output_path(args, source_path, ".ir.json")
|
|
194
|
+
ir_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
ir_path.write_text(json.dumps(module, indent=2) + "\n", encoding="utf-8")
|
|
196
|
+
outputs["ir"] = str(ir_path)
|
|
197
|
+
if module["fences"]:
|
|
198
|
+
state["fence_code"] = module["fences"][0].get("code")
|
|
199
|
+
log.info(
|
|
200
|
+
"check.fences",
|
|
201
|
+
count=len(module["fences"]),
|
|
202
|
+
codes=[fence["code"] for fence in module["fences"]],
|
|
203
|
+
)
|
|
204
|
+
print(render_fences(module, source), file=sys.stderr)
|
|
205
|
+
status = "fences"
|
|
206
|
+
return 3
|
|
207
|
+
log.info("emit.ir", output=str(ir_path))
|
|
208
|
+
if args.emit == "ir":
|
|
209
|
+
print(ir_path)
|
|
210
|
+
return 0
|
|
211
|
+
call_args = _parse_args(args.args) if args.call else None
|
|
212
|
+
dbg.begin("emit_llvm")
|
|
213
|
+
llvm_text = emit_llvm(module, args.call, call_args, standalone=args.emit == "bin")
|
|
214
|
+
dbg.end("emit_llvm")
|
|
215
|
+
ll_path = _output_path(args, source_path, ".ll")
|
|
216
|
+
ll_path.write_text(llvm_text, encoding="utf-8")
|
|
217
|
+
outputs["ll"] = str(ll_path)
|
|
218
|
+
log.info("emit.llvm", output=str(ll_path))
|
|
219
|
+
if args.emit == "llvm":
|
|
220
|
+
print(ll_path)
|
|
221
|
+
return 0
|
|
222
|
+
binary = Path(args.output) if args.output else source_path.with_suffix("")
|
|
223
|
+
dbg.begin("link")
|
|
224
|
+
link_binary(ll_path, binary, driver=args.driver, target=args.target, record=dbg.commands)
|
|
225
|
+
dbg.end("link")
|
|
226
|
+
outputs["bin"] = str(binary)
|
|
227
|
+
log.info("link.binary", output=str(binary), driver=args.driver, target=args.target)
|
|
228
|
+
try:
|
|
229
|
+
state["ran_bytes"] = binary.stat().st_size
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
if not args.run:
|
|
233
|
+
print(binary)
|
|
234
|
+
return 0
|
|
235
|
+
log.info("run.start", binary=str(binary))
|
|
236
|
+
dbg.begin("run")
|
|
237
|
+
completed = subprocess.run([str(binary.absolute())])
|
|
238
|
+
dbg.end("run")
|
|
239
|
+
code = 128 + (-completed.returncode) if completed.returncode < 0 else completed.returncode
|
|
240
|
+
log.info("run.exit", binary=str(binary), code=code)
|
|
241
|
+
return code
|
|
242
|
+
except Exception:
|
|
243
|
+
status = "error"
|
|
244
|
+
raise
|
|
245
|
+
finally:
|
|
246
|
+
report = dbg.render(module, outputs)
|
|
247
|
+
if report:
|
|
248
|
+
print(report, file=sys.stderr)
|
|
249
|
+
dbg.write_dir(module, llvm_text, outputs, status)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_args(text: str) -> list[Any]:
|
|
253
|
+
try:
|
|
254
|
+
value = ast.literal_eval(text)
|
|
255
|
+
except (SyntaxError, ValueError) as exc:
|
|
256
|
+
raise IRError(f"--args is not a Python literal: {exc}") from exc
|
|
257
|
+
if not isinstance(value, list):
|
|
258
|
+
raise IRError("--args must be a list literal")
|
|
259
|
+
return list(value)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _output_path(args: argparse.Namespace, source: Path, suffix: str) -> Path:
|
|
263
|
+
if args.output and args.emit == "ir" and suffix == ".ir.json":
|
|
264
|
+
return Path(args.output)
|
|
265
|
+
if args.output and args.emit == "llvm" and suffix == ".ll":
|
|
266
|
+
return Path(args.output)
|
|
267
|
+
if args.output and suffix == ".ll":
|
|
268
|
+
return Path(str(args.output) + ".ll")
|
|
269
|
+
if args.output and suffix == ".ir.json":
|
|
270
|
+
return Path(str(args.output) + ".ir.json")
|
|
271
|
+
return source.with_suffix(suffix)
|