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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ 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`:
Names are resolved to canonical symbols through lexical scopes, imports and builtins.
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.
Relative imports resolve against the module's dotted name, derived from the enclosing
`__init__.py` packages. Builtins resolve to `python.builtins.<name>`. Names introduced by
wildcard imports stay unresolved and are emitted as `global`.
2 changes: 1 addition & 1 deletion src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def module(self, tree: ast.Module) -> nodes.Module:
)
else:
span = SourceSpan(self._source.source_id, 1, 1, 1, 1)
return nodes.Module(body, span)
return nodes.Module(self._source.module_name, body, span)


def build_module(source: SourceFile, tree: ast.Module) -> nodes.Module:
Expand Down
1 change: 1 addition & 0 deletions src/coretrace_python/hir/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ class Class:

@dataclass(frozen=True, slots=True)
class Module:
name: str
body: tuple[Statement, ...]
span: SourceSpan

18 changes: 8 additions & 10 deletions src/coretrace_python/ir/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
Value,
ValueInstruction,
)
from coretrace_python.semantic.imports import ImportBindings, collect_imports
from coretrace_python.semantic.imports import analyze_imports
from coretrace_python.semantic.scopes import (
Resolution,
ResolutionKind,
Scope,
ScopeAnalysis,
analyze_scopes,
)
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, analyze_symbols


class LoweringError(Exception):
Expand All @@ -42,7 +42,7 @@ class LoweringError(Exception):

@dataclass
class _FunctionLowerer:
imports: ImportBindings
symbols: SymbolAnalysis
scopes: ScopeAnalysis
scope: Scope
next_value_id: int = 0
Expand Down Expand Up @@ -75,9 +75,7 @@ def resolve(self, name: str) -> Resolution:

def imported_symbol(self, node: nodes.Expression) -> SymbolId | None:
if isinstance(node, nodes.Name):
if self.resolve(node.identifier).kind is not ResolutionKind.GLOBAL:
return None
return self.imports.resolve(node.identifier)
return self.symbols.resolve(self.scope.id, node.identifier)
if isinstance(node, nodes.Attribute):
parent = self.imported_symbol(node.value)
return parent.attribute(node.name) if parent is not None else None
Expand Down Expand Up @@ -139,8 +137,8 @@ def statement(self, node: nodes.Statement) -> None:
if isinstance(node, nodes.ExpressionStatement):
self.expression(node.expression)
return
if isinstance(node, (nodes.Pass, nodes.Global)):
# ``global`` is a declaration already applied by scope analysis.
if isinstance(node, (nodes.Pass, nodes.Global, nodes.Import, nodes.ImportFrom)):
# Declarations and imports are already applied by the semantic analyses.
return
self.fail(node)

Expand All @@ -154,12 +152,12 @@ def function(self, node: nodes.Function) -> FunctionIR:


def lower_module(module: nodes.Module) -> ModuleIR:
imports = collect_imports(module)
scopes = analyze_scopes(module)
symbols = analyze_symbols(scopes, analyze_imports(module, scopes))
functions: list[FunctionIR] = []
for statement in module.body:
if isinstance(statement, nodes.Function):
lowerer = _FunctionLowerer(imports, scopes, scopes.scope_for(statement))
lowerer = _FunctionLowerer(symbols, scopes, scopes.scope_for(statement))
functions.append(lowerer.function(statement))
elif isinstance(statement, (nodes.Import, nodes.ImportFrom)):
continue
Expand Down
146 changes: 90 additions & 56 deletions src/coretrace_python/semantic/imports.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,99 @@
"""Collection of module-level import bindings."""
"""Import analysis: per-scope bindings from import statements (architecture §4.2).

No module is ever imported. Each binding maps the local name an import introduces to
the canonical path of the object it refers to, resolved statically from the statement
and, for relative imports, from the importing module's dotted name.
"""

from __future__ import annotations

from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from collections.abc import Mapping
from types import MappingProxyType

from coretrace_python.hir import nodes
from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId
from coretrace_python.semantic.symbols import SymbolId

_NO_BINDINGS: Mapping[str, SymbolId] = MappingProxyType({})

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(node: nodes.ImportFrom, message: str) -> ImportResolutionError:
return ImportResolutionError(f"{node.span.display()}: {message}")


def collect_imports(module: nodes.Module) -> ImportBindings:
"""Collect supported top-level imports without importing any modules."""

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

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


class ImportAnalysis:
"""Immutable import bindings and wildcard imports, keyed by scope."""

def __init__(
self,
bindings: Mapping[ScopeId, Mapping[str, SymbolId]],
wildcards: Mapping[ScopeId, tuple[SymbolId, ...]],
) -> None:
self._bindings = MappingProxyType(
{scope_id: MappingProxyType(dict(found)) for scope_id, found in bindings.items()}
)
self._wildcards = MappingProxyType(dict(wildcards))

def bindings(self, scope_id: ScopeId) -> Mapping[str, SymbolId]:
return self._bindings.get(scope_id, _NO_BINDINGS)

def wildcards(self, scope_id: ScopeId) -> tuple[SymbolId, ...]:
return self._wildcards.get(scope_id, ())


class _Collector:
def __init__(self, module: nodes.Module, scopes: ScopeAnalysis) -> None:
self.package = module.name.rpartition(".")[0]
self.scopes = scopes
self.bindings: dict[ScopeId, dict[str, SymbolId]] = {}
self.wildcards: dict[ScopeId, list[SymbolId]] = {}
self.body(module.body, scopes.module_scope.id)

def body(self, statements: tuple[nodes.Statement, ...], scope_id: ScopeId) -> None:
for statement in statements:
if isinstance(statement, nodes.Import):
for alias in statement.names:
# ``import a.b.c`` binds ``a``; ``import a.b.c as d`` binds the full path.
top_level = alias.name.partition(".")[0]
if alias.as_name is None:
self.bind(scope_id, top_level, top_level)
else:
self.bind(scope_id, alias.as_name, alias.name)
elif isinstance(statement, nodes.ImportFrom):
base = self.base_module(statement)
for alias in statement.names:
if alias.name == "*":
self.wildcards.setdefault(scope_id, []).append(
SymbolId.from_python_path(base)
)
else:
self.bind(scope_id, alias.as_name or alias.name, f"{base}.{alias.name}")
elif isinstance(statement, (nodes.Function, nodes.Class)):
self.body(statement.body, self.scopes.scope_for(statement).id)

def base_module(self, statement: nodes.ImportFrom) -> str:
if not statement.level:
assert statement.module is not None, "absolute import without a module"
return statement.module
parts = self.package.split(".") if self.package else []
kept = len(parts) - (statement.level - 1)
if kept < 1:
raise ImportResolutionError(
f"{statement.span.display()}: relative import beyond top-level package"
)
base = ".".join(parts[:kept])
return f"{base}.{statement.module}" if statement.module else base

def bind(self, scope_id: ScopeId, local_name: str, canonical_path: str) -> None:
symbol = SymbolId.from_python_path(canonical_path)
self.bindings.setdefault(scope_id, {})[local_name] = symbol


def analyze_imports(module: nodes.Module, scopes: ScopeAnalysis) -> ImportAnalysis:
"""Collect every import binding of ``module`` without importing anything."""

collector = _Collector(module, scopes)
return ImportAnalysis(
collector.bindings,
{scope_id: tuple(found) for scope_id, found in collector.wildcards.items()},
)
43 changes: 39 additions & 4 deletions src/coretrace_python/semantic/symbols.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
"""Canonical symbol identities stored in PyIR."""
"""Canonical symbol identities and their resolution (architecture §4.3).

A ``SymbolId`` names a Python object independently of how a file spells it: every way
of importing ``os.system`` yields ``python.os.system``. Framework models may live in
their own namespaces, such as ``flask.request.args``.
"""

from __future__ import annotations

import builtins
from dataclasses import dataclass
from typing import TYPE_CHECKING

from coretrace_python.semantic.scopes import ResolutionKind, ScopeAnalysis, ScopeId

if TYPE_CHECKING:
from coretrace_python.semantic.imports import ImportAnalysis

BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith("_"))


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

canonical_name: str

def __post_init__(self) -> None:
if not self.canonical_name.startswith("python."):
raise ValueError("canonical Python symbols must start with 'python.'")
components = self.canonical_name.split(".")
if len(components) < 2:
raise ValueError("a canonical symbol needs a namespace and a path")
if not all(component.isidentifier() for component in components):
raise ValueError(f"invalid canonical symbol: {self.canonical_name!r}")

@classmethod
def from_python_path(cls, path: str) -> SymbolId:
Expand All @@ -30,3 +47,21 @@ def attribute(self, name: str) -> SymbolId:
def __str__(self) -> str:
return self.canonical_name


class SymbolAnalysis:
"""Resolve names to canonical symbols through scopes, imports and builtins."""

def __init__(self, scopes: ScopeAnalysis, imports: ImportAnalysis) -> None:
self._scopes = scopes
self._imports = imports

def resolve(self, scope_id: ScopeId, name: str) -> SymbolId | None:
resolution = self._scopes.resolve(scope_id, name)
if resolution.kind is ResolutionKind.UNBOUND:
return SymbolId(f"python.builtins.{name}") if name in BUILTIN_NAMES else None
assert resolution.scope is not None
return self._imports.bindings(resolution.scope).get(name)


def analyze_symbols(scopes: ScopeAnalysis, imports: ImportAnalysis) -> SymbolAnalysis:
return SymbolAnalysis(scopes, imports)
26 changes: 23 additions & 3 deletions src/coretrace_python/source/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,36 @@
from coretrace_python.source.model import SourceFile, SourceId


def module_name_for(path: Path) -> str:
"""Dotted module name of ``path``, walking up through ``__init__.py`` packages."""

parts = [path.stem]
directory = path.parent
while (directory / "__init__.py").is_file():
parts.append(directory.name)
directory = directory.parent
return ".".join(reversed(parts))


class SourceManager:
"""Own source text and return one canonical object for each source ID."""

def __init__(self) -> None:
self._sources: dict[SourceId, SourceFile] = {}

def add_source(self, name: str, text: str) -> SourceFile:
def add_source(self, name: str, text: str, module_name: str | None = None) -> SourceFile:
source_id = SourceId(name)
existing = self._sources.get(source_id)
if existing is not None:
if existing.text != text:
raise ValueError(f"source {name!r} already exists with different text")
return existing

source = SourceFile(source_id=source_id, text=text)
source = SourceFile(
source_id=source_id,
text=text,
module_name=module_name if module_name is not None else Path(name).stem,
)
self._sources[source_id] = source
return source

Expand All @@ -34,7 +49,12 @@ def load_file(self, path: str | Path) -> SourceFile:

# utf-8-sig accepts ordinary UTF-8 and strips a leading UTF-8 BOM.
text = resolved_path.read_text(encoding="utf-8-sig")
source = SourceFile(source_id=source_id, text=text, path=resolved_path)
source = SourceFile(
source_id=source_id,
text=text,
module_name=module_name_for(resolved_path),
path=resolved_path,
)
self._sources[source_id] = source
return source

Expand Down
3 changes: 2 additions & 1 deletion src/coretrace_python/source/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ def display(self) -> str:

@dataclass(frozen=True)
class SourceFile:
"""Decoded source text and its identity."""
"""Decoded source text, its identity and its dotted module name."""

source_id: SourceId
text: str
module_name: str
path: Path | None = None

2 changes: 1 addition & 1 deletion tests/test_emit_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def test_emit_ir_for_call(tmp_path, capsys) -> None:

assert main(["--emit-ir", str(source)]) == 0
output = capsys.readouterr().out
assert "%1 = global 'print'" in output
assert "%1 = symbol @python.builtins.print" in output
assert "%2 = call %1(%0)" in output


Expand Down
Loading
Loading