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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ Currently supported inside functions: arguments, assignments, names, constants,
operations, unary operations, comparisons, calls, attribute access, indexing, and returns.
Unsupported syntax produces a source-located diagnostic and a non-zero exit status.

Module-level imports are resolved to canonical symbols. Import aliases do not change an
API's identity, so all of the following resolve to `python.os.system`:

```python
import os
from os import system
from os import system as run
```

Relative and wildcard imports are not supported yet and produce source-located diagnostics.
5 changes: 5 additions & 0 deletions examples/canonical_symbols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import os as operating_system


def execute(command):
operating_system.system(command)
4 changes: 2 additions & 2 deletions src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys
from pathlib import Path

from coretrace_python.frontend.imports import ImportResolutionError
from coretrace_python.frontend.lowering import LoweringError, lower_module
from coretrace_python.frontend.parser import ParseError, parse_file
from coretrace_python.ir.printer import format_module
Expand Down Expand Up @@ -32,7 +33,7 @@ def main(argv: list[str] | None = None) -> int:
try:
tree = parse_file(args.path)
module = lower_module(tree, filename=str(args.path))
except (OSError, ParseError, LoweringError) as error:
except (OSError, ParseError, ImportResolutionError, LoweringError) as error:
print(f"error: {error}", file=sys.stderr)
return 1

Expand All @@ -42,4 +43,3 @@ def main(argv: list[str] | None = None) -> int:

if __name__ == "__main__":
raise SystemExit(main())

67 changes: 67 additions & 0 deletions src/coretrace_python/frontend/imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Collection of module-level import bindings."""

from __future__ import annotations

import ast
from collections.abc import Iterator, Mapping
from dataclasses import dataclass

from coretrace_python.ir.symbol import SymbolId


class ImportResolutionError(Exception):
"""A source-located import that cannot be represented safely."""


@dataclass(frozen=True)
class ImportBindings(Mapping[str, SymbolId]):
"""Map names visible in a module to their canonical identities."""

_bindings: dict[str, SymbolId]

def __getitem__(self, name: str) -> SymbolId:
return self._bindings[name]

def __iter__(self) -> Iterator[str]:
return iter(self._bindings)

def __len__(self) -> int:
return len(self._bindings)

def resolve(self, name: str) -> SymbolId | None:
return self._bindings.get(name)


def _error(filename: str, node: ast.AST, message: str) -> ImportResolutionError:
line = getattr(node, "lineno", 1)
column = getattr(node, "col_offset", 0) + 1
return ImportResolutionError(f"{filename}:{line}:{column}: {message}")


def collect_imports(tree: ast.Module, filename: str = "<unknown>") -> ImportBindings:
"""Collect supported top-level imports without importing any modules."""

bindings: dict[str, SymbolId] = {}
for statement in tree.body:
if isinstance(statement, ast.Import):
for alias in statement.names:
if alias.asname is None:
local_name = alias.name.partition(".")[0]
canonical_path = local_name
else:
local_name = alias.asname
canonical_path = alias.name
bindings[local_name] = SymbolId.from_python_path(canonical_path)
elif isinstance(statement, ast.ImportFrom):
if statement.level:
raise _error(filename, statement, "relative imports are not supported yet")
if statement.module is None:
raise _error(filename, statement, "import module is missing")
for alias in statement.names:
if alias.name == "*":
raise _error(filename, statement, "wildcard imports are not supported")
local_name = alias.asname or alias.name
canonical_path = f"{statement.module}.{alias.name}"
bindings[local_name] = SymbolId.from_python_path(canonical_path)
return ImportBindings(bindings)

32 changes: 28 additions & 4 deletions src/coretrace_python/frontend/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import ast
from dataclasses import dataclass, field

from coretrace_python.frontend.imports import ImportBindings, collect_imports
from coretrace_python.frontend.symbols import SymbolId
from coretrace_python.ir.model import (
BasicBlock,
BinaryOp,
Expand All @@ -18,8 +20,10 @@
Return,
SourceLocation,
StoreLocal,
Symbol,
UnaryOp,
Value,
ValueInstruction,
)


Expand Down Expand Up @@ -60,6 +64,7 @@ class LoweringError(Exception):
@dataclass
class _FunctionLowerer:
filename: str
imports: ImportBindings
next_value_id: int = 0
locals: dict[str, Value] = field(default_factory=dict)
block: BasicBlock = field(default_factory=lambda: BasicBlock("entry"))
Expand All @@ -86,12 +91,28 @@ def new_value(self) -> Value:
self.next_value_id += 1
return value

def emit(self, instruction):
def emit(self, instruction: ValueInstruction) -> Value:
self.block.instructions.append(instruction)
return instruction.result

def emit_effect(self, instruction: StoreLocal | Return) -> None:
self.block.instructions.append(instruction)

def imported_symbol(self, node: ast.expr) -> SymbolId | None:
if isinstance(node, ast.Name):
if node.id in self.locals or node.id in self.parameters:
return None
return self.imports.resolve(node.id)
if isinstance(node, ast.Attribute):
parent = self.imported_symbol(node.value)
return parent.attribute(node.attr) if parent is not None else None
return None

def expression(self, node: ast.expr) -> Value:
location = self.location(node)
imported_symbol = self.imported_symbol(node)
if imported_symbol is not None:
return self.emit(Symbol(self.new_value(), location, imported_symbol))
if isinstance(node, ast.Name):
if node.id in self.locals:
return self.emit(LoadLocal(self.new_value(), location, node.id))
Expand Down Expand Up @@ -159,12 +180,12 @@ def statement(self, node: ast.stmt) -> None:
self.fail(node, "only assignment to one local name is supported")
name = node.targets[0].id
value = self.expression(node.value)
self.emit(StoreLocal(None, self.location(node), name, value))
self.emit_effect(StoreLocal(None, self.location(node), name, value))
self.locals[name] = value
return
if isinstance(node, ast.Return):
value = self.expression(node.value) if node.value is not None else None
self.emit(Return(None, self.location(node), value))
self.emit_effect(Return(None, self.location(node), value))
return
if isinstance(node, ast.Expr):
self.expression(node.value)
Expand All @@ -190,10 +211,13 @@ def function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionIR:


def lower_module(tree: ast.Module, filename: str = "<unknown>") -> ModuleIR:
imports = collect_imports(tree, filename)
functions: list[FunctionIR] = []
for statement in tree.body:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
functions.append(_FunctionLowerer(filename).function(statement))
functions.append(_FunctionLowerer(filename, imports).function(statement))
elif isinstance(statement, (ast.Import, ast.ImportFrom)):
continue
elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant):
# Permit module docstrings.
continue
Expand Down
5 changes: 5 additions & 0 deletions src/coretrace_python/frontend/symbols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Frontend support for canonical Python symbols."""

from coretrace_python.ir.symbol import SymbolId

__all__ = ["SymbolId"]
16 changes: 13 additions & 3 deletions src/coretrace_python/ir/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from dataclasses import dataclass
from typing import TypeAlias

from coretrace_python.ir.symbol import SymbolId


@dataclass(frozen=True)
class SourceLocation:
Expand Down Expand Up @@ -37,6 +39,13 @@ class Global:
name: str


@dataclass(frozen=True)
class Symbol:
result: Value
location: SourceLocation
symbol_id: SymbolId


@dataclass(frozen=True)
class BinaryOp:
result: Value
Expand Down Expand Up @@ -109,19 +118,20 @@ class StoreLocal:
value: Value


Instruction: TypeAlias = (
ValueInstruction: TypeAlias = (
Constant
| Global
| Symbol
| BinaryOp
| UnaryOp
| Compare
| Call
| GetAttr
| GetItem
| LoadLocal
| StoreLocal
| Return
)
EffectInstruction: TypeAlias = StoreLocal | Return
Instruction: TypeAlias = ValueInstruction | EffectInstruction


@dataclass(frozen=True)
Expand Down
3 changes: 3 additions & 0 deletions src/coretrace_python/ir/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ModuleIR,
Return,
StoreLocal,
Symbol,
UnaryOp,
Value,
)
Expand All @@ -27,6 +28,8 @@ def _instruction(instruction: Instruction) -> str:
return f"{_value(instruction.result)} = const {instruction.value!r}"
if isinstance(instruction, Global):
return f"{_value(instruction.result)} = global {instruction.name!r}"
if isinstance(instruction, Symbol):
return f"{_value(instruction.result)} = symbol @{instruction.symbol_id}"
if isinstance(instruction, BinaryOp):
return (
f"{_value(instruction.result)} = binary.{instruction.operator} "
Expand Down
32 changes: 32 additions & 0 deletions src/coretrace_python/ir/symbol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Canonical symbol identities stored in PyIR."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class SymbolId:
"""A stable, import-style-independent name for a Python object."""

canonical_name: str

def __post_init__(self) -> None:
if not self.canonical_name.startswith("python."):
raise ValueError("canonical Python symbols must start with 'python.'")

@classmethod
def from_python_path(cls, path: str) -> SymbolId:
normalized_path = path.removeprefix("python.")
if not normalized_path or normalized_path.startswith("."):
raise ValueError("a Python symbol path cannot be empty")
return cls(f"python.{normalized_path}")

def attribute(self, name: str) -> SymbolId:
if not name or "." in name:
raise ValueError("an attribute name must be one non-empty component")
return SymbolId(f"{self.canonical_name}.{name}")

def __str__(self) -> str:
return self.canonical_name

39 changes: 39 additions & 0 deletions tests/test_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

import pytest

from coretrace_python.frontend.imports import (
ImportBindings,
ImportResolutionError,
collect_imports,
)
from coretrace_python.frontend.parser import parse_source
from coretrace_python.ir.symbol import SymbolId


def bindings_for(source: str) -> ImportBindings:
return collect_imports(parse_source(source, "imports.py"), "imports.py")


def test_collects_plain_and_aliased_imports() -> None:
bindings = bindings_for("import os\nimport subprocess as sp\n")
assert bindings["os"] == SymbolId("python.os")
assert bindings["sp"] == SymbolId("python.subprocess")


def test_plain_dotted_import_binds_its_top_level_package() -> None:
bindings = bindings_for("import xml.etree.ElementTree\n")
assert bindings["xml"] == SymbolId("python.xml")


def test_collects_from_imports() -> None:
bindings = bindings_for("from os import system as run\n")
assert bindings["run"] == SymbolId("python.os.system")


def test_wildcard_import_is_rejected_with_location() -> None:
with pytest.raises(
ImportResolutionError,
match=r"imports.py:1:1: wildcard imports are not supported",
):
bindings_for("from os import *\n")
Loading
Loading