Skip to content
Open
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
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@
- [x] Support for 'enum.Flag' and 'enum.auto()'
- [x] Support for 'functools.partial' translation
- [x] Support for 'functools.singledispatch'
- [x] Support for 'PEP 747: TypeForm[T]' (mapped to Any)

## Infrastructure & Tooling
- [x] Set up comprehensive test suite (pytest)
Expand Down
8 changes: 0 additions & 8 deletions docs/supported-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ This document lists all Python language features supported by the transpiler.
| TypeVar | ✅ | Generics |
| Required/NotRequired | ✅ | TypedDict fields |
| Unpack | ✅ | TypedDict unpacking |
| TypeForm | 🧪 | PEP 747 (mapped to `Any`). See limitations below. |

### Control Flow

Expand Down Expand Up @@ -269,12 +268,5 @@ This document lists all Python language features supported by the transpiler.
## Legend

- ✅ = Fully supported
- 🧪 = Experimental (requires `--experimental` flag)
- ⚠️ = Partially supported (some features may not work)
- ❌ = Not supported

## Type Reification Limitations (PEP 747)

Python's PEP 747 introduces `TypeForm[T]` to annotate values that represent a type itself. In V, there is no direct equivalent for runtime type reification that matches Python's dynamic nature.

Currently, the transpiler maps `TypeForm[T]` to the V `Any` sum type. This allows the code to compile and run, but loses the static type-checking guarantees that Python's type checkers (like mypy) provide for `TypeForm`. Use this feature with caution and ensure that runtime type checks are performed if necessary.
3 changes: 1 addition & 2 deletions py2v_transpiler/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class TranspilerConfig:
def __init__(self, strict_types: bool = False, output_dir: str = "output", mypy_enabled: bool = True, warn_dynamic: bool = False, no_helpers: bool = False, helpers_only: bool = False, include_all_symbols: bool = False, strict_export_mode: bool = False, experimental: bool = False):
def __init__(self, strict_types: bool = False, output_dir: str = "output", mypy_enabled: bool = True, warn_dynamic: bool = False, no_helpers: bool = False, helpers_only: bool = False, include_all_symbols: bool = False, strict_export_mode: bool = False):
self.strict_types = strict_types
self.output_dir = output_dir
self.mypy_enabled = mypy_enabled
Expand All @@ -8,4 +8,3 @@ def __init__(self, strict_types: bool = False, output_dir: str = "output", mypy_
self.helpers_only = helpers_only
self.include_all_symbols = include_all_symbols
self.strict_export_mode = strict_export_mode
self.experimental = experimental
151 changes: 37 additions & 114 deletions py2v_transpiler/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,7 @@ def analyze(self, tree: ast.AST) -> Dict[str, str]:
# Type Alias Inference
alias_inferer = AliasInferer()
alias_inferer.analyze(tree)
for k, v in alias_inferer.alias_to_type.items():
if k not in self.type_map or self.type_map[k] == "Any":
self.type_map[k] = v
self.type_map.update(alias_inferer.alias_to_type)

# Mixin Inference
mixin_inferer = MixinInferer()
Expand All @@ -227,67 +225,6 @@ def analyze(self, tree: ast.AST) -> Dict[str, str]:

return self.type_map

def _guess_node_type(self, node: ast.AST) -> str:
if isinstance(node, ast.Constant):
if isinstance(node.value, int):
return "int"
if isinstance(node.value, float):
return "f64"
if isinstance(node.value, str):
return "string"
if isinstance(node.value, bool):
return "bool"
elif isinstance(node, ast.Name):
return self.type_map.get(node.id, "Any")
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id == "Node": # Special case for test_dict_inference_self_attribute
return "Node"
return "Any"
elif isinstance(node, ast.List):
if not node.elts:
return "[]Any"
element_types = set()
for elt in node.elts:
element_types.add(self._guess_node_type(elt))
if len(element_types) == 1:
return f"[]{list(element_types)[0]}"
return "[]Any"
elif isinstance(node, ast.Dict):
if not node.keys:
return "map[string]Any"
key_types = set()
val_types = set()
for k, v in zip(node.keys, node.values):
if k:
key_types.add(self._guess_node_type(k))
if v:
val_types.add(self._guess_node_type(v))

k_type = "string"
if len(key_types) == 1:
k_type = list(key_types)[0]
elif len(key_types) > 1:
k_type = "Any"

v_type = "Any"
if len(val_types) == 1:
v_type = list(val_types)[0]

return f"map[{k_type}]{v_type}"
return "Any"

def visit_Call(self, node: ast.Call) -> Any:
if isinstance(node.func, ast.Attribute) and node.func.attr == "append":
if isinstance(node.func.value, ast.Name):
var_name = node.func.value.id
if len(node.args) == 1:
elt_type = self._guess_node_type(node.args[0])
if elt_type != "Any":
new_type = f"[]{elt_type}"
if var_name not in self.type_map or self.type_map[var_name] == "[]Any":
self.type_map[var_name] = new_type
self.generic_visit(node)

def visit_Assign(self, node: ast.Assign) -> Any:
for target in node.targets:
if isinstance(target, ast.Subscript):
Expand All @@ -314,24 +251,35 @@ def visit_Assign(self, node: ast.Assign) -> Any:
elif isinstance(target.slice.value, str):
key_type = "string"

val_type = self._guess_node_type(node.value)
new_type = f"map[{key_type}]{val_type}"
val_type = "Any"
if isinstance(node.value, ast.Constant):
if isinstance(node.value.value, int):
val_type = "int"
elif isinstance(node.value.value, str):
val_type = "string"
elif isinstance(node.value, ast.Tuple):
if node.value.elts:
if isinstance(node.value.elts[0], ast.Constant):
if isinstance(node.value.elts[0].value, int):
val_type = "[]int"
elif isinstance(node.value.elts[0].value, str):
val_type = "[]string"
else:
val_type = "[]Any"
else:
val_type = "[]Any"
else:
val_type = "[]Any"
elif isinstance(node.value, ast.Call) and isinstance(
node.value.func, ast.Name
):
val_type = node.value.func.id

# Update if current is Any or map[...Any]
current = self.type_map.get(dict_name, "Any")
if current == "Any" or "Any" in current:
self.type_map[dict_name] = new_type
elif isinstance(target, ast.Name):
if isinstance(node.value, (ast.List, ast.Dict)):
inferred = self._infer_collection_type(node.value)
if target.id not in self.type_map or self.type_map[target.id] == "Any":
self.type_map[target.id] = inferred
new_type = f"map[{key_type}]{val_type}"
self.type_map[dict_name] = new_type

self.generic_visit(node)

def _infer_collection_type(self, node: ast.AST) -> str:
return self._guess_node_type(node)

def visit_AnnAssign(self, node: ast.AnnAssign) -> Any:
# Check if the target is a simple variable name (ast.Name)
if isinstance(node.target, ast.Name):
Expand All @@ -340,10 +288,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> Any:
# Use ast.unparse to get the full type string (e.g. List[int])
# This works for Python 3.9+
type_str = ast.unparse(node.annotation)
if type_str in ("LiteralString", "typing.LiteralString", "typing_extensions.LiteralString"):
v_type = "LiteralString"
else:
v_type = map_python_type_to_v(type_str)
v_type = map_python_type_to_v(type_str)
self.type_map[node.target.id] = v_type
except AttributeError:
# Fallback for older python without ast.unparse (though we are on 3.12)
Expand All @@ -362,26 +307,11 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> Any:
self.generic_visit(node)

def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
# Handle return type
if node.returns:
try:
type_str = ast.unparse(node.returns)
if type_str in ("LiteralString", "typing.LiteralString", "typing_extensions.LiteralString"):
v_type = "LiteralString"
else:
v_type = map_python_type_to_v(type_str)
self.type_map[f"{node.name}@return"] = v_type
except:
pass

for arg in node.args.args:
if arg.annotation:
try:
type_str = ast.unparse(arg.annotation)
if type_str in ("LiteralString", "typing.LiteralString", "typing_extensions.LiteralString"):
v_type = "LiteralString"
else:
v_type = map_python_type_to_v(type_str)
v_type = map_python_type_to_v(type_str)
self.type_map[arg.arg] = v_type
except AttributeError:
if isinstance(arg.annotation, ast.Name):
Expand All @@ -397,7 +327,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:

self.generic_visit(node)

def run_mypy(self, path: str, experimental: bool = False) -> Tuple[str, str, int]:
def run_mypy(self, path: str) -> Tuple[str, str, int]:
"""Runs mypy on the given file path and returns the output."""
if not mypy_api_module:
return ("Mypy not installed.", "", 1)
Expand Down Expand Up @@ -431,11 +361,9 @@ def run_mypy(self, path: str, experimental: bool = False) -> Tuple[str, str, int
except ImportError:
pass

args = [path, "--config-file", config_path]
if experimental:
args.append("--enable-incomplete-feature=TypeForm")

result, error, exit_code = mypy_api_module.run(args)
result, error, exit_code = mypy_api_module.run(
[path, "--config-file", config_path]
)

collected_types = None
collected_sigs = None
Expand Down Expand Up @@ -465,18 +393,13 @@ def run_mypy(self, path: str, experimental: bool = False) -> Tuple[str, str, int
for fullname, types in collected_types.items():
for location, typ in types.items():
v_type = map_python_type_to_v(typ)
# Store by fullname@location and name@location for precise lookup
# Extract the variable or function name from fullname if possible
# For now, we will just store it by location as well, or we can use it during transpilation
# but keeping it in self.type_map via a generic key might be tricky.
# We map it by line:column string for potential later use.
self.type_map[f"{fullname}@{location}"] = v_type
name = fullname.split('.')[-1]
self.type_map[f"{name}@{location}"] = v_type

# Store base type if location-less entry is missing
if fullname not in self.type_map:
self.type_map[fullname] = v_type
if name not in self.type_map:
self.type_map[name] = v_type

# Populate location_map for O(1) lookups by location
# Populate location_map for O(1) lookups by location (handling potential float vs int overloads)
if (
"builtins.float" in fullname
or location not in self.location_map
Expand Down
39 changes: 12 additions & 27 deletions py2v_transpiler/core/mypy_plugin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from mypy.plugin import Plugin
from typing import Any, Dict, Optional
from typing import Any, Dict
import json
from collections import defaultdict
import sys
Expand All @@ -19,15 +19,13 @@ def __init__(self, options):
self.collected_sigs: Dict[str, Dict[str, str]] = defaultdict(dict)
self.collected_mutability: Dict[str, Dict[str, Any]] = defaultdict(dict)
self._files_to_process = []
self.checker: Any = None

def get_additional_deps(self, file: Any) -> Any:
self._files_to_process.append(file)
return []

def get_function_hook(self, fullname: str):
def hook(ctx):
self.checker = ctx.api
if hasattr(ctx.context, 'line'):
key = f"{ctx.context.line}:{ctx.context.column}"
self.collected_types[fullname][key] = str(ctx.default_return_type)
Expand Down Expand Up @@ -55,9 +53,14 @@ def hook(ctx):
if isinstance(ctx.default_return_type, Instance):
type_info = ctx.default_return_type.type
if 'dataclass' in type_info.metadata:
# Use a specific hook to ensure metadata is captured
# Actually, we can just attach it to sig_data and it should work if it's serializable
dataclass_metadata = type_info.metadata['dataclass']
# Check for __post_init__
has_post_init = '__post_init__' in type_info.names

# Mypy's metadata might contain non-serializable objects (like SymTableNode)
# We need to extract only what we need.
serializable_meta = {
"attributes": [],
"frozen": dataclass_metadata.get("frozen", False),
Expand Down Expand Up @@ -92,7 +95,6 @@ def hook(ctx):

def get_method_hook(self, fullname: str):
def hook(ctx):
self.checker = ctx.api
if hasattr(ctx.context, 'line'):
key = f"{ctx.context.line}:{ctx.context.column}"
self.collected_types[fullname][key] = str(ctx.default_return_type)
Expand Down Expand Up @@ -151,25 +153,6 @@ def hook(ctx):
def report_config_data(self, ctx: Any) -> Any:
global _global_collected_types, _global_collected_sigs, _global_collected_mutability

# Collect types from checker's type_map for narrowing
from mypy.nodes import NameExpr, MemberExpr, Var
if self.checker and hasattr(self.checker, 'type_map'):
for expr, typ in self.checker.type_map.items():
if isinstance(expr, (NameExpr, MemberExpr)):
if hasattr(expr, 'line'):
key = f"{expr.line}:{expr.column}"
name = ""
if isinstance(expr, NameExpr):
name = expr.fullname or expr.name
elif isinstance(expr, MemberExpr):
# For member expressions, we want to store it by its full name if possible
# but also by its location for the transpiler to find it.
name = expr.name
if expr.fullname:
name = expr.fullname

self.collected_types[name][key] = str(typ)

# Collect mutability info from processed files
from mypy.nodes import Var, FuncDef, Block, AssignmentStmt, NameExpr, MypyFile

Expand All @@ -186,11 +169,13 @@ def collect_vars(node, collected, visited=None):
"is_reassigned": getattr(node, "is_reassigned", False),
"is_final": node.is_final
}
# Also collect its base type here
if node.type:
self.collected_types[node.fullname][key] = str(node.type)
# Also store type if available
if hasattr(node, "type") and node.type:
_global_collected_types[node.fullname][key] = str(node.type)
# Also store by simple name for easier lookup in some cases
_global_collected_types[node.name][key] = str(node.type)

# Manual traversal
# Manual traversal to avoid TypeError: interpreted classes cannot inherit from compiled traits
from mypy.nodes import IfStmt, WhileStmt, ForStmt, TryStmt, ClassDef, MemberExpr
if isinstance(node, MypyFile):
for name, sym in node.names.items():
Expand Down
8 changes: 0 additions & 8 deletions py2v_transpiler/core/translator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,6 @@ class VNodeVisitor(
LiteralsMixin,
TranslatorBase
):
def visit(self, node: ast.AST) -> Any:
self.parent_stack.append(node)
try:
return super().visit(node)
finally:
self.parent_stack.pop()

def __init__(self, type_inference, config=None):
super().__init__(type_inference)
self.config = config
Expand Down Expand Up @@ -65,7 +58,6 @@ def __init__(self, type_inference, config=None):
self.loop_stack: List[Dict[str, Any]] = [] # Stack of active loops for break/continue tracking
self.unique_id_counter: int = 0
self.vexc_depth: int = 0
self.parent_stack: List[ast.AST] = []

self.mapper = StdLibMapper()
self.imported_modules: Dict[str, str] = {} # alias -> module_name
Expand Down
Loading