Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ def _build(paths: list[Path], cache: Path, out: Path | None) -> int:
why = f" ({', '.join(m.reasons)})" if m.reasons else ""
what = m.extension.name if m.extension else "no extension (interpreted)"
print(f"{m.status:7} {m.name}: {what}{why}")
for name, reason in sorted(m.skipped.items()):
print(f" interpreted: {name} — {reason}")
rel = m.path.relative_to(root).as_posix()
if m.kind == "not_marked":
print(f" {rel}: not marked for compilation (no `# typedpython: compiled`, no @compiled)")
for item in m.skipped_items:
where = f"{item.file}:{item.line}" if item.line is not None else item.file
print(f" {where}: {item.name}: {item.message}")
if out is not None and m.extension is not None:
out.mkdir(parents=True, exist_ok=True)
shutil.copy2(m.extension, out / m.extension.name)
Expand Down
13 changes: 10 additions & 3 deletions python-multiplatform-ksp/src/main/python/typedpython/cgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,16 @@ def kw_index(self, kw: tuple[str, ...]) -> int:
return self.kwtuples.index(kw)


def generate(module: ir.Module, source_path: Path) -> str:
"""One C file implementing `module`; `source_path` is the original Python source, embedded."""
def generate(module: ir.Module, source_path: Path, display_path: str | None = None) -> str:
"""One C file implementing `module`; `source_path` is the original Python source, embedded.

`display_path` is the path embedded as the code object's filename (tracebacks of the interpreted
fallback): the module's path relative to the project root, posix separators (#112). It never
defaults to the absolute host path, so the C does not depend on where the project was built;
left out, it is the file name.
"""
source_path = Path(source_path)
display_path = source_path.name if display_path is None else display_path
source = source_path.read_text(encoding="utf-8")
mg = _ModGen(module)
for f in module.functions:
Expand All @@ -438,7 +445,7 @@ def generate(module: ir.Module, source_path: Path) -> str:
w("#pragma STDC FP_CONTRACT OFF")
w("")
w(f"static const char tp_source[] =\n {c_string(source)};")
w(f"static const char tp_source_path[] = {c_string(str(source_path))};")
w(f"static const char tp_source_path[] = {c_string(display_path)};")
w("")
n_names, n_kw = len(mg.names), len(mg.kwtuples)
w("/* Module state: interned names (globals, attributes, keywords, function names) and the")
Expand Down
13 changes: 13 additions & 0 deletions python-multiplatform-ksp/src/main/python/typedpython/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,19 @@ def _whole_module(source: str) -> bool:
return next((line.strip() for line in source.splitlines() if line.strip()), "") == MARKER


def is_opted_in(path: Path) -> bool:
"""Whether the front end considers the module for compilation at all: the `# typedpython: compiled`
marker as its first non-empty line, or a module-level `def` / `class` decorated `@compiled`
(the same tests `_targets` and `_classes` apply)."""
source = Path(path).read_text()
if _whole_module(source):
return True
return any(
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and any(isinstance(d, ast.Name) and d.id == "compiled" for d in node.decorator_list)
for node in ast.parse(source, filename=str(path)).body)


def _classes(
tree: ast.Module, source: str, info: _ModuleInfo,
) -> tuple[dict[str, ir.ClassDecl], dict[str, str]]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
The front end, the C back end and the C compiler are passed in as callables, so this file depends on
none of them (only on `ir`, the contract).

The module's relative path is in the key because the C embeds it (#112): the same content moved to
another path must not reuse an extension carrying the old path.

Key encoding (v1): sha256 of the compact, key-sorted JSON of
{"v": 1, "source": sha256-hex(source bytes),
{"v": 1, "path": <module path relative to project root, posix>, "source": sha256-hex(source bytes),
"deps": [[<dep path relative to project root, posix>, <dep interface hash>], ...] sorted,
"compiler_version": str, "platform": str, "flags": [str, ...] in the given order}
Interface hash (v1): sha256 of the compact, key-sorted JSON of
Expand Down Expand Up @@ -267,7 +270,7 @@ def build_project(self, sources: list[Path], project_root: Path, *,
dep_hashes[rel(d)] = "src:" + _sha(d.read_bytes())
comps = {"source": source_hash, "deps": dep_hashes, "compiler_version": compiler_version,
"platform": platform, "flags": list(flags)}
key = _sha(_canon({"v": _V, "source": source_hash, "deps": sorted(dep_hashes.items()),
key = _sha(_canon({"v": _V, "path": rel(p), "source": source_hash, "deps": sorted(dep_hashes.items()),
"compiler_version": compiler_version, "platform": platform,
"flags": list(flags)}))
name = _module_name(p, root)
Expand Down
72 changes: 62 additions & 10 deletions python-multiplatform-ksp/src/main/python/typedpython/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""
import hashlib
import json
import re
import sysconfig
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -33,6 +34,11 @@ class Result:
diagnostics: list[verify.Diagnostic] = field(default_factory=list)


def _relative(path: Path, root: Path) -> str:
"""The module's path relative to the project root, posix separators (what the C embeds)."""
return Path(path).resolve().relative_to(Path(root).resolve()).as_posix()


def _gate_or_raise(path: Path) -> None:
errors = [d for d in gate.check([path], mode="compiled") if d.severity == "error"]
if errors:
Expand All @@ -47,8 +53,12 @@ def compile_module(
python_include: Path | None = None,
ext_suffix: str | None = None,
runtime_dir: Path | None = None,
project_root: Path | None = None,
) -> Result:
"""`project_root`: the root the embedded source path is relative to (#112); default: the source's
own directory, so the file name alone is embedded."""
path = Path(path).resolve()
root = path.parent if project_root is None else Path(project_root).resolve()
_gate_or_raise(path)

lowered = frontend.lower(path)
Expand All @@ -57,7 +67,7 @@ def compile_module(
if not proved.functions:
return Result(path, proved, None, skipped, diagnostics)

c_source = cgen.generate(proved, path)
c_source = cgen.generate(proved, path, _relative(path, root))
extension = cbuild.build(
c_source, path.stem, Path(out_dir),
runtime_dir=runtime_dir, python_include=python_include, ext_suffix=ext_suffix,
Expand Down Expand Up @@ -98,6 +108,36 @@ def _tree_hash(*dirs: Path) -> str:
_NO_EXTENSION = ".noext" # cache artefact of a module with nothing compiled


@dataclass(frozen=True)
class SkippedItem:
"""A function or class that stays interpreted, and why (pypackpack's native-level report)."""
name: str
file: str # package-relative, posix
line: int | None
message: str # without the "line N: " prefix


_LINE_PREFIX = re.compile(r"line (\d+): ")


def skipped_items(skipped: dict[str, str], diagnostics: list[verify.Diagnostic], file: str) -> list[SkippedItem]:
"""Structure `ir.Module.skipped` (name -> text, which stays as it is: the incremental interface
hash reads its keys only). The front end writes `line N: reason`; the verifier writes `rule:
message` and records the line in its diagnostic for the function."""
lines: dict[str, int] = {}
for d in diagnostics:
lines.setdefault(d.function, d.source_line)
out = []
for name, text in sorted(skipped.items()):
m = _LINE_PREFIX.match(text)
if m:
line, message = int(m.group(1)), text[m.end():]
else:
line, message = lines.get(name), text
out.append(SkippedItem(name, file, line, message))
return out


@dataclass
class ModuleResult:
path: Path
Expand All @@ -107,6 +147,8 @@ class ModuleResult:
reasons: list[str] = field(default_factory=list) # why it was rebuilt (empty if reused)
skipped: dict[str, str] = field(default_factory=dict) # function -> why it stays interpreted
interface: str = ""
kind: str = "compiled" # "compiled" | "nothing_compiled" | "not_marked"
skipped_items: list[SkippedItem] = field(default_factory=list) # `skipped`, structured


@dataclass
Expand All @@ -129,16 +171,19 @@ def compile_project(
) -> ProjectResult:
sources = [Path(s).resolve() for s in sources]
cache_dir = Path(cache_dir)
verified: dict[Path, ir.Module] = {}
verified: dict[Path, list[SkippedItem]] = {}
verified_module_skipped: dict[Path, dict[str, str]] = {}
root = Path(project_root).resolve()

def lower(path: Path) -> ir.Module:
_gate_or_raise(path)
proved, _ = verify.verify(frontend.lower(path))
verified[path] = proved # the VERIFIED module: its signatures are what gets compiled
proved, diagnostics = verify.verify(frontend.lower(path))
verified_module_skipped[path] = dict(proved.skipped)
verified[path] = skipped_items(proved.skipped, diagnostics, _relative(path, root)) # the VERIFIED module: its signatures are what gets compiled
return proved

def generate(module: ir.Module, path: Path) -> str:
return cgen.generate(module, path) if module.functions else ""
return cgen.generate(module, path, _relative(path, root)) if module.functions else ""

def compile_c(c_source: str, name: str, out_dir: Path) -> Path:
if not c_source:
Expand Down Expand Up @@ -166,11 +211,18 @@ def compile_c(c_source: str, name: str, out_dir: Path) -> Path:
for m in report.modules:
record = side / f"{m.key}.json"
if m.status == "rebuilt":
record.write_text(json.dumps(dict(verified[m.path].skipped)))
record.write_text(json.dumps({
"skipped": dict(verified_module_skipped[m.path]),
"items": [vars(i) for i in verified[m.path]]}))
try:
skipped = json.loads(record.read_text())
except (OSError, ValueError):
skipped = {}
data = json.loads(record.read_text())
skipped = dict(data["skipped"])
items = [SkippedItem(**i) for i in data["items"]]
except (OSError, ValueError, KeyError, TypeError):
skipped, items = {}, []
ext = None if m.artifact is None or m.artifact.suffix == _NO_EXTENSION else m.artifact
out.append(ModuleResult(m.path, m.name, ext, m.status, list(m.reasons), skipped, m.interface))
kind = ("not_marked" if not frontend.is_opted_in(m.path)
else "compiled" if ext is not None else "nothing_compiled")
out.append(ModuleResult(m.path, m.name, ext, m.status, list(m.reasons), skipped, m.interface,
kind, items))
return ProjectResult(out)
Loading
Loading