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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.9.0] - 2026-05-01

### Added
- **Generic type parsing** - Full support for generic type annotations in Python code
- Parse `list[T]`, `dict[K, V]`, `set[T]`, `tuple[T, ...]` generic types
- Parse union types: `X | None`, `int | str | None`
- Parse `Optional[T]` from typing module
- Parse nested generics: `list[dict[str, int]]`, `dict[str, list[int]]`
- Support typing module aliases: `List[int]`, `Dict[str, Any]`
- **User Outcome**: Eliminates false positive warnings from type inference
- Created `type_utils.parse_type_annotation()` helper for consistent type parsing
- Updated `ASTExtractor._get_type_string()` to use helper
- Updated `TypeInferrer._get_type_string()` to use helper
- All 312 unit tests passing (20 new tests added)

### Changed
- Type annotations now preserve full generic information instead of erasing to base type
- Before: `list[int]` → stored as "list"
- After: `list[int]` → stored as "list[int]"
- Type inference validation now correctly compares generic types

## [0.8.4] - 2026-05-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,4 @@ Review these documents to understand patterns and best practices:
---

**Last Updated**: 2026-05-01
**Current Version**: 0.8.4
**Current Version**: 0.9.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Mapper (Application Mapper)

![Version](https://img.shields.io/badge/version-0.8.4-blue.svg)
![Version](https://img.shields.io/badge/version-0.9.0-blue.svg)
![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-tests.json&cacheSeconds=300)
![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ydkadri/9501806ed5eac873dd324bc606c6dd79/raw/mapper-coverage.json&cacheSeconds=300)
![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mapper"
version = "0.8.4"
version = "0.9.0"
description = "Mapper (Application Mapper) - AST-based Python code analyzer with Neo4j graph storage"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -93,7 +93,7 @@ addopts = [
testpaths = ["tests"]

[tool.bumpversion]
current_version = "0.8.4"
current_version = "0.9.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
Expand Down
2 changes: 1 addition & 1 deletion src/mapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Public modules for programmatic access
from mapper import analyser, graph, graph_loader

__version__ = "0.8.4"
__version__ = "0.9.0"

__all__ = [
# Version
Expand Down
10 changes: 5 additions & 5 deletions src/mapper/ast_parser/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from mapper import name_resolver
from mapper.ast_parser import models
from mapper.type_inference import type_utils


class ASTExtractor:
Expand Down Expand Up @@ -421,17 +422,16 @@ def _extract_call(self, node: ast.Call) -> models.CallInfo | None:
def _get_type_string(self, node: ast.expr) -> str:
"""Convert type annotation node to string.

Supports simple types, generic types (list[int], dict[str, Any]),
union types (str | None), and Optional types.

Args:
node: AST type annotation node

Returns:
Type as string
"""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Constant):
return str(node.value)
return "Unknown"
return type_utils.parse_type_annotation(node)

def _get_attribute_string(self, node: ast.Attribute) -> str:
"""Convert attribute node to string.
Expand Down
11 changes: 5 additions & 6 deletions src/mapper/type_inference/inferrer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import ast

from mapper import ast_parser
from mapper.type_inference import models
from mapper.type_inference import models, type_utils


class TypeInferrer:
Expand Down Expand Up @@ -177,14 +177,13 @@ def _infer_from_expression(self, node: ast.expr) -> str | None:
def _get_type_string(self, node: ast.expr) -> str:
"""Convert type annotation node to string.

Supports simple types, generic types (list[int], dict[str, Any]),
union types (str | None), and Optional types.

Args:
node: AST type annotation node

Returns:
Type as string
"""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Constant):
return str(node.value)
return "Unknown"
return type_utils.parse_type_annotation(node)
60 changes: 60 additions & 0 deletions src/mapper/type_inference/type_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Utilities for parsing and handling type annotations."""

import ast


def parse_type_annotation(node: ast.expr) -> str:
"""Parse a type annotation AST node into a string representation.

Handles:
- Simple types: str, int, CustomClass
- Generic types: list[int], dict[str, Any]
- Union types: str | None, int | str
- Optional types: Optional[str]

Args:
node: AST expression node representing a type annotation

Returns:
String representation of the type

Examples:
>>> # ast.Name(id='str') -> 'str'
>>> # ast.Subscript(value=Name('list'), slice=Name('int')) -> 'list[int]'
>>> # ast.BinOp(left=Name('str'), op=BitOr(), right=Name('None')) -> 'str | None'
"""
# Simple name: str, int, CustomClass
if isinstance(node, ast.Name):
return node.id

# Subscripted generic: list[T], dict[K, V], Optional[T]
elif isinstance(node, ast.Subscript):
base = parse_type_annotation(node.value)

# Handle single subscript: list[int], Optional[str]
if isinstance(node.slice, ast.Name):
arg = node.slice.id
return f"{base}[{arg}]"

# Handle multiple subscripts: dict[str, int]
elif isinstance(node.slice, ast.Tuple):
args = [parse_type_annotation(elt) for elt in node.slice.elts]
return f"{base}[{', '.join(args)}]"

# Recursively handle complex subscripts
else:
arg = parse_type_annotation(node.slice)
return f"{base}[{arg}]"

# Union type with | operator: str | None, int | str | None
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
left = parse_type_annotation(node.left)
right = parse_type_annotation(node.right)
return f"{left} | {right}"

# Constant (edge case - might appear in some type contexts)
elif isinstance(node, ast.Constant):
return str(node.value)

# Unknown or unsupported type annotation
return "Unknown"
38 changes: 38 additions & 0 deletions tests/unit/ast_parser/test_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,44 @@ def returns_user() -> User:
func = result.functions[0]
assert func.return_type == "User"

def test_extract_generic_type_annotations(self):
"""Test extracting generic type annotations (list, dict, Optional, union)."""
code = textwrap.dedent("""
def process_list(items: list[str]) -> list[int]:
return [1, 2, 3]

def process_dict(data: dict[str, Any]) -> dict[str, int]:
return {"count": 5}

def optional_result() -> str | None:
return None

def optional_param(value: Optional[int]) -> int:
return value or 0
""")

extractor = ast_parser.ASTExtractor(code, "module.py")
result = extractor.extract()

# Test list[str] -> list[int]
process_list = next(f for f in result.functions if f.name == "process_list")
assert process_list.parameters[0].type_hint == "list[str]"
assert process_list.return_type == "list[int]"

# Test dict[str, Any] -> dict[str, int]
process_dict = next(f for f in result.functions if f.name == "process_dict")
assert process_dict.parameters[0].type_hint == "dict[str, Any]"
assert process_dict.return_type == "dict[str, int]"

# Test union type: str | None
optional_result = next(f for f in result.functions if f.name == "optional_result")
assert optional_result.return_type == "str | None"

# Test Optional[int]
optional_param = next(f for f in result.functions if f.name == "optional_param")
assert optional_param.parameters[0].type_hint == "Optional[int]"
assert optional_param.return_type == "int"

def test_extract_invalid_syntax(self):
"""Test extracting from code with syntax errors."""
code = "def invalid syntax here"
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/type_inference/test_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,35 @@ def no_return():
# Functions with no return statement implicitly return None
assert result.inferred_type == "None"
assert result.confidence == "high"

def test_infer_with_generic_type_annotations(self):
"""Test that generic type annotations are properly extracted and used."""
code = textwrap.dedent(
"""
def process_list() -> list[int]:
return [1, 2, 3]

def process_dict() -> dict[str, Any]:
return {"key": "value"}

def optional_result() -> str | None:
if True:
return "result"
return None
"""
)

inferrer = self._create_inferrer(code)

# Test that generic type annotations are properly extracted
list_result = inferrer.infer_function_return("process_list")
assert list_result.inferred_type == "list[int]"
assert list_result.confidence == "high"

dict_result = inferrer.infer_function_return("process_dict")
assert dict_result.inferred_type == "dict[str, Any]"
assert dict_result.confidence == "high"

optional_result = inferrer.infer_function_return("optional_result")
assert optional_result.inferred_type == "str | None"
assert optional_result.confidence == "high"
Loading
Loading