diff --git a/README.md b/README.md index 431e073..4846ea6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/canonical_symbols.py b/examples/canonical_symbols.py new file mode 100644 index 0000000..941bc55 --- /dev/null +++ b/examples/canonical_symbols.py @@ -0,0 +1,5 @@ +import os as operating_system + + +def execute(command): + operating_system.system(command) diff --git a/src/coretrace_python/cli.py b/src/coretrace_python/cli.py index e5ce027..75efa82 100644 --- a/src/coretrace_python/cli.py +++ b/src/coretrace_python/cli.py @@ -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 @@ -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 @@ -42,4 +43,3 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/src/coretrace_python/frontend/imports.py b/src/coretrace_python/frontend/imports.py new file mode 100644 index 0000000..46adf16 --- /dev/null +++ b/src/coretrace_python/frontend/imports.py @@ -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 = "") -> 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) + diff --git a/src/coretrace_python/frontend/lowering.py b/src/coretrace_python/frontend/lowering.py index 155384d..a85e9c6 100644 --- a/src/coretrace_python/frontend/lowering.py +++ b/src/coretrace_python/frontend/lowering.py @@ -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, @@ -18,8 +20,10 @@ Return, SourceLocation, StoreLocal, + Symbol, UnaryOp, Value, + ValueInstruction, ) @@ -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")) @@ -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)) @@ -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) @@ -190,10 +211,13 @@ def function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionIR: def lower_module(tree: ast.Module, filename: str = "") -> 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 diff --git a/src/coretrace_python/frontend/symbols.py b/src/coretrace_python/frontend/symbols.py new file mode 100644 index 0000000..9bc20b1 --- /dev/null +++ b/src/coretrace_python/frontend/symbols.py @@ -0,0 +1,5 @@ +"""Frontend support for canonical Python symbols.""" + +from coretrace_python.ir.symbol import SymbolId + +__all__ = ["SymbolId"] diff --git a/src/coretrace_python/ir/model.py b/src/coretrace_python/ir/model.py index 7ccaa22..0119845 100644 --- a/src/coretrace_python/ir/model.py +++ b/src/coretrace_python/ir/model.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import TypeAlias +from coretrace_python.ir.symbol import SymbolId + @dataclass(frozen=True) class SourceLocation: @@ -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 @@ -109,9 +118,10 @@ class StoreLocal: value: Value -Instruction: TypeAlias = ( +ValueInstruction: TypeAlias = ( Constant | Global + | Symbol | BinaryOp | UnaryOp | Compare @@ -119,9 +129,9 @@ class StoreLocal: | GetAttr | GetItem | LoadLocal - | StoreLocal - | Return ) +EffectInstruction: TypeAlias = StoreLocal | Return +Instruction: TypeAlias = ValueInstruction | EffectInstruction @dataclass(frozen=True) diff --git a/src/coretrace_python/ir/printer.py b/src/coretrace_python/ir/printer.py index db5a385..c0d24bd 100644 --- a/src/coretrace_python/ir/printer.py +++ b/src/coretrace_python/ir/printer.py @@ -13,6 +13,7 @@ ModuleIR, Return, StoreLocal, + Symbol, UnaryOp, Value, ) @@ -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} " diff --git a/src/coretrace_python/ir/symbol.py b/src/coretrace_python/ir/symbol.py new file mode 100644 index 0000000..7ad8cca --- /dev/null +++ b/src/coretrace_python/ir/symbol.py @@ -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 + diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..b8ba75a --- /dev/null +++ b/tests/test_imports.py @@ -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") diff --git a/tests/test_symbol_resolution.py b/tests/test_symbol_resolution.py new file mode 100644 index 0000000..ec04881 --- /dev/null +++ b/tests/test_symbol_resolution.py @@ -0,0 +1,121 @@ +"""Acceptance tests for imports and canonical symbol resolution.""" + +from __future__ import annotations + +import pytest + +from coretrace_python.cli import main + + +def emit_ir(source_text: str, tmp_path, capsys) -> tuple[int, str, str]: + source = tmp_path / "symbols.py" + source.write_text(source_text, encoding="utf-8") + + exit_code = main(["--emit-ir", str(source)]) + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + +@pytest.mark.parametrize( + "import_statement, call_expression", + [ + ("import os", "os.system(command)"), + ("import os as operating_system", "operating_system.system(command)"), + ("from os import system", "system(command)"), + ("from os import system as run", "run(command)"), + ], + ids=["module", "module-alias", "from-import", "from-import-alias"], +) +def test_os_system_import_styles_resolve_to_the_same_symbol( + import_statement: str, + call_expression: str, + tmp_path, + capsys, +) -> None: + exit_code, output, error = emit_ir( + f"{import_statement}\n\n" + "def execute(command):\n" + f" {call_expression}\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert "symbol @python.os.system" in output + assert "global 'os'" not in output + assert "global 'operating_system'" not in output + assert "global 'system'" not in output + assert "global 'run'" not in output + + +def test_nested_module_symbol_is_canonicalized(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "import subprocess as sp\n\n" + "def execute(command):\n" + " sp.run(command)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert "symbol @python.subprocess.run" in output + assert "global 'sp'" not in output + + +def test_import_bindings_are_available_to_every_function(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "from os import system\n\n" + "def first(command):\n" + " system(command)\n\n" + "def second(command):\n" + " system(command)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert output.count("symbol @python.os.system") == 2 + assert "func @first(%0)" in output + assert "func @second(%0)" in output + + +def test_non_imported_name_remains_a_global(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "def greet(name):\n" + " print(name)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert "%1 = global 'print'" in output + assert "symbol @python.print" not in output + + +def test_local_assignment_shadows_an_import_binding(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "from os import system as run\n\n" + "def execute(callback, command):\n" + " run = callback\n" + " run(command)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert 'store_local "run", %0' in output + assert 'load_local "run"' in output + assert "symbol @python.os.system" not in output + + +def test_wildcard_import_fails_with_a_source_location(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "from os import *\n", + tmp_path, + capsys, + ) + + assert exit_code == 1 + assert output == "" + assert "symbols.py:1:1" in error + assert "wildcard imports are not supported" in error diff --git a/tests/test_symbols.py b/tests/test_symbols.py new file mode 100644 index 0000000..59bf899 --- /dev/null +++ b/tests/test_symbols.py @@ -0,0 +1,24 @@ +import pytest + +from coretrace_python.frontend.symbols import SymbolId + + +def test_symbol_id_adds_the_python_namespace() -> None: + symbol = SymbolId.from_python_path("os.system") + assert symbol.canonical_name == "python.os.system" + + +def test_symbol_id_does_not_duplicate_the_python_namespace() -> None: + symbol = SymbolId.from_python_path("python.os.system") + assert symbol.canonical_name == "python.os.system" + + +def test_symbol_attribute_creates_a_child_identity() -> None: + module = SymbolId.from_python_path("os") + assert module.attribute("system") == SymbolId("python.os.system") + + +@pytest.mark.parametrize("path", ["", ".os"]) +def test_invalid_symbol_paths_are_rejected(path: str) -> None: + with pytest.raises(ValueError): + SymbolId.from_python_path(path)