Module System¶
Package Structure¶
Packages are directories containing an optional __init__.spy file:
The __init__.spy file can re-export symbols for convenient imports:
# utils/__init__.spy
from utils.helpers import format_string, parse_input
from utils.math.vectors import Vector2, Vector3
Circular Import Handling¶
Current Behavior¶
All circular imports are rejected at compile time with diagnostic SPY0302. When ModuleLoader detects that a module being loaded is already on the import chain (tracked via _importChain Stack), it emits an error with the full cycle path and returns null, preventing the module from loading.
# file: parent.spy
from child import Child # ERROR: SPY0302 circular import detected
class Parent:
children: list[Child]
# file: child.spy
from parent import Parent # ERROR: SPY0302 circular import detected
class Child:
parent: Parent?
The workaround today is to restructure code to break the cycle, typically by extracting shared types into a third module that both sides import.
RFC: Allow Circular Imports for Type-Annotation-Only References¶
Status: RFC -- implementation deferred to a dedicated issue.
Motivation: Mutually-referential types are common in domain modeling (e.g., Parent/Child, Order/LineItem, Node/Edge). Requiring a third "types-only" module to break the cycle is boilerplate that does not exist in the target .NET platform, where circular type references within an assembly are resolved naturally by the CLR.
Proposed behavior: Circular imports are allowed when every imported symbol from the cycle is used ONLY in type annotation positions -- not at runtime. Specifically, a "type-annotation-only" usage means the symbol appears exclusively in:
- Field type annotations (e.g.,
other: ClassB) - Parameter type annotations (e.g.,
def f(self, b: ClassB) -> None) - Return type annotations (e.g.,
-> ClassB) - Generic type arguments in annotations (e.g.,
list[ClassB]) - Variable declaration type annotations (e.g.,
x: ClassB = ...)
The following usages are NOT type-annotation-only and would still require a non-circular import:
- Base class references (e.g.,
class Foo(ClassB)) - Constructor calls (e.g.,
ClassB()) - Static method or attribute access (e.g.,
ClassB.create()) isinstancechecks (e.g.,isinstance(x, ClassB))- Any expression-position usage of the imported name
Example of proposed behavior:
# file: parent.spy
from child import Child # OK - Child used only in type annotations
class Parent:
children: list[Child] # Type annotation - resolved in type resolution phase
def add_child(self, c: Child) -> None: # Type annotation
self.children.append(c)
# file: child.spy
from parent import Parent # OK - Parent used only in type annotations
class Child:
parent: Parent? # Type annotation - resolved in type resolution phase
Implementation touchpoints:
| Component | File(s) | Change |
|---|---|---|
| Circular import detection | ModuleLoader._importChain, IsModuleInChain() |
Defer rejection; record the cycle instead of immediately emitting SPY0302 |
| Import resolution | ImportResolver.ResolveImports() |
Track which imported symbols come from a deferred-cycle module |
| Usage classification | New analysis pass or TypeChecker extension | Walk AST to classify each use of a deferred-cycle symbol as type-annotation-only vs. runtime |
| Error emission | ModuleLoader, ImportResolver |
Emit SPY0302 only for cycles where at least one symbol has a runtime usage |
Approach sketch -- two-pass import system:
-
Pass 1 (type stub collection): When a circular import is detected, instead of emitting an error, create a "stub"
ModuleInfocontaining only type declarations (class/struct/interface/enum names and their type parameters). Register these stubs in the symbol table so that type annotations can resolve against them. Mark the import as "deferred." -
Pass 2 (full resolution): After all modules have completed Pass 1, revisit deferred imports and attempt full resolution. At this point, all type names are known, so type annotations resolve correctly. If a deferred-cycle symbol is used in a non-annotation position, emit SPY0302 with a message clarifying that circular imports are only permitted for type annotations.
This approach aligns with the existing semantic pipeline: stub collection maps naturally onto NameResolver.ResolveDeclarations() (Pass 1), and full resolution maps onto TypeResolver.ResolveTypes() (Pass 2) and TypeChecker.CheckModule() (Pass 3).
Design considerations:
- Base class cycles remain forbidden. A class cannot extend a base from a circular import because inheritance resolution (
NameResolver.ResolveInheritance()) requires the full base type, not just a stub. - Incremental compilation. The
IncrementalCompilationCachealready tracks file dependencies. Deferred-cycle imports would add bidirectional edges to the dependency graph, causing both files to recompile when either changes. This is correct behavior. - Error quality. When a cycle is rejected because a symbol has runtime usage, the error message should identify which symbol and which usage site caused the rejection, not just show the cycle path.
- No new syntax required. Unlike Python's
from __future__ import annotationsorTYPE_CHECKINGguard, Sharpy can detect annotation-only usage statically at compile time because all type information is resolved ahead of time.