spartastruct 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """SpartaStruct — Instant architecture diagrams for any Python codebase."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Package init."""
@@ -0,0 +1,127 @@
1
+ """Core data model for SpartaStruct analysis results.
2
+
3
+ All analysis modules produce instances of these dataclasses, and all
4
+ diagram generators and renderers consume them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass
13
+ class ParamInfo:
14
+ """A single function/method parameter with optional type annotation."""
15
+
16
+ name: str
17
+ type: str = "Any"
18
+
19
+
20
+ @dataclass
21
+ class AttributeInfo:
22
+ """A class attribute or instance variable."""
23
+
24
+ name: str
25
+ type: str = "Any"
26
+ visibility: str = "public" # "public", "protected", "private"
27
+
28
+
29
+ @dataclass
30
+ class MethodInfo:
31
+ """A class method with full signature information."""
32
+
33
+ name: str
34
+ params: list[ParamInfo] = field(default_factory=list)
35
+ return_type: str = "None"
36
+ decorators: list[str] = field(default_factory=list)
37
+ is_async: bool = False
38
+ calls: list[str] = field(default_factory=list)
39
+ visibility: str = "public" # "public", "protected", "private"
40
+
41
+
42
+ @dataclass
43
+ class ClassInfo:
44
+ """A Python class with all its members and metadata."""
45
+
46
+ name: str
47
+ bases: list[str] = field(default_factory=list)
48
+ decorators: list[str] = field(default_factory=list)
49
+ attributes: list[AttributeInfo] = field(default_factory=list)
50
+ methods: list[MethodInfo] = field(default_factory=list)
51
+ is_abstract: bool = False
52
+ is_dataclass: bool = False
53
+ orm_type: str | None = None # "sqlalchemy", "django", "tortoise", "peewee"
54
+
55
+
56
+ @dataclass
57
+ class FunctionInfo:
58
+ """A module-level function."""
59
+
60
+ name: str
61
+ params: list[ParamInfo] = field(default_factory=list)
62
+ return_type: str = "None"
63
+ decorators: list[str] = field(default_factory=list)
64
+ is_async: bool = False
65
+ calls: list[str] = field(default_factory=list)
66
+
67
+
68
+ @dataclass
69
+ class ImportInfo:
70
+ """Classified imports for a single Python file."""
71
+
72
+ stdlib: list[str] = field(default_factory=list)
73
+ third_party: list[str] = field(default_factory=list)
74
+ local: list[str] = field(default_factory=list) # local project import edges
75
+
76
+
77
+ @dataclass
78
+ class RouteInfo:
79
+ """An HTTP route definition (FastAPI, Flask, or Django)."""
80
+
81
+ method: str # "GET", "POST", "PUT", "DELETE", etc. or "ANY" for Flask catch-all
82
+ path: str
83
+ handler_name: str
84
+
85
+
86
+ @dataclass
87
+ class FileResult:
88
+ """Analysis results for a single Python file."""
89
+
90
+ path: str # relative path from project root
91
+ classes: list[ClassInfo] = field(default_factory=list)
92
+ functions: list[FunctionInfo] = field(default_factory=list)
93
+ imports: ImportInfo = field(default_factory=ImportInfo)
94
+ routes: list[RouteInfo] = field(default_factory=list)
95
+
96
+
97
+ @dataclass
98
+ class AnalysisResult:
99
+ """Top-level container for an entire project analysis run."""
100
+
101
+ files_analyzed: list[FileResult] = field(default_factory=list)
102
+ frameworks: list[str] = field(default_factory=list)
103
+ entry_points: list[str] = field(default_factory=list) # relative file paths
104
+ warnings: list[str] = field(default_factory=list)
105
+ llm_calls_succeeded: int = 0
106
+ files_skipped_large: int = 0
107
+ files_skipped_parse_error: int = 0
108
+
109
+ @property
110
+ def all_classes(self) -> list[ClassInfo]:
111
+ """Return all ClassInfo objects across all analyzed files."""
112
+ return [cls for fr in self.files_analyzed for cls in fr.classes]
113
+
114
+ @property
115
+ def all_functions(self) -> list[FunctionInfo]:
116
+ """Return all FunctionInfo objects across all analyzed files."""
117
+ return [fn for fr in self.files_analyzed for fn in fr.functions]
118
+
119
+ @property
120
+ def all_routes(self) -> list[RouteInfo]:
121
+ """Return all RouteInfo objects across all analyzed files."""
122
+ return [r for fr in self.files_analyzed for r in fr.routes]
123
+
124
+ @property
125
+ def orm_models(self) -> list[ClassInfo]:
126
+ """Return all classes that are ORM models."""
127
+ return [cls for cls in self.all_classes if cls.orm_type is not None]
@@ -0,0 +1,20 @@
1
+ """Protocol defining the interface every language analyzer must satisfy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from spartastruct.analyzer.base import AnalysisResult
9
+
10
+
11
+ class BaseAnalyzer(Protocol):
12
+ """Every language analyzer must implement this interface."""
13
+
14
+ def analyze(
15
+ self,
16
+ files: list[Path],
17
+ warnings: list[str] | None = None,
18
+ ) -> AnalysisResult:
19
+ """Analyze a list of source files and return a complete AnalysisResult."""
20
+ ...
@@ -0,0 +1,233 @@
1
+ """Regex-based analyzer for JavaScript and TypeScript source files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from spartastruct.analyzer.base import (
9
+ AnalysisResult,
10
+ ClassInfo,
11
+ FileResult,
12
+ FunctionInfo,
13
+ ImportInfo,
14
+ MethodInfo,
15
+ RouteInfo,
16
+ )
17
+
18
+ # Class / interface declarations
19
+ _CLASS_RE = re.compile(
20
+ r"(?:export\s+)?(?:abstract\s+)?(?:class|interface)\s+(\w+)"
21
+ r"(?:\s+extends\s+([\w,\s<>]+?))?(?:\s+implements\s+([\w,\s<>]+?))?"
22
+ r"\s*\{",
23
+ re.MULTILINE,
24
+ )
25
+
26
+ # Method inside a class body
27
+ _METHOD_RE = re.compile(
28
+ r"(?:(?:public|private|protected|static|async|override|abstract|readonly)\s+)*"
29
+ r"(\w+)\s*\([^)]*\)\s*(?::\s*[\w<>\[\]|&\s]+?)?\s*[{;]",
30
+ re.MULTILINE,
31
+ )
32
+ _METHOD_SKIP = frozenset(
33
+ {"if", "for", "while", "switch", "catch", "super", "return", "new", "throw"}
34
+ )
35
+
36
+ # Named function declarations
37
+ _FUNC_DECL_RE = re.compile(
38
+ r"(?:export\s+)?(?P<async>async\s+)?function\s+(?P<name>\w+)\s*\(",
39
+ re.MULTILINE,
40
+ )
41
+
42
+ # Arrow / expression functions: const foo = (async?) (...) =>
43
+ _ARROW_RE = re.compile(
44
+ r"(?:export\s+)?(?:const|let|var)\s+(?P<name>\w+)\s*=\s*(?P<async>async\s+)?\(",
45
+ re.MULTILINE,
46
+ )
47
+
48
+ # ES module imports
49
+ _IMPORT_FROM_RE = re.compile(
50
+ r"""import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]""",
51
+ re.MULTILINE | re.DOTALL,
52
+ )
53
+
54
+ # CommonJS requires
55
+ _REQUIRE_RE = re.compile(
56
+ r"""(?:const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)""",
57
+ re.MULTILINE,
58
+ )
59
+
60
+ # Express routes
61
+ _EXPRESS_ROUTE_RE = re.compile(
62
+ r"(?:app|router)\.(get|post|put|patch|delete|all)\s*\(\s*['\"`]([^'\"`]+)['\"`]",
63
+ re.MULTILINE | re.IGNORECASE,
64
+ )
65
+
66
+ # NestJS decorator routes
67
+ _NESTJS_ROUTE_RE = re.compile(
68
+ r"@(Get|Post|Put|Patch|Delete|All)\s*\(\s*(?:['\"`]([^'\"`]*)['\"`])?\s*\)",
69
+ re.MULTILINE,
70
+ )
71
+
72
+ _JS_ENTRY_NAMES = frozenset(
73
+ {"index.js", "index.ts", "main.js", "main.ts", "app.js", "app.ts", "server.js", "server.ts"}
74
+ )
75
+
76
+
77
+ def _extract_class_body(source: str, brace_pos: int) -> str:
78
+ """Return the text between the first matching { } pair starting at brace_pos."""
79
+ depth = 0
80
+ start = None
81
+ for i in range(brace_pos, len(source)):
82
+ ch = source[i]
83
+ if ch == "{":
84
+ if start is None:
85
+ start = i + 1
86
+ depth += 1
87
+ elif ch == "}":
88
+ depth -= 1
89
+ if depth == 0 and start is not None:
90
+ return source[start:i]
91
+ return ""
92
+
93
+
94
+ def _extract_methods(class_body: str) -> list[MethodInfo]:
95
+ methods: list[MethodInfo] = []
96
+ seen: set[str] = set()
97
+ for m in _METHOD_RE.finditer(class_body):
98
+ name = m.group(1)
99
+ if name in _METHOD_SKIP or name in seen:
100
+ continue
101
+ seen.add(name)
102
+ methods.append(MethodInfo(name=name))
103
+ return methods
104
+
105
+
106
+ def _classify_import(module_path: str) -> tuple[str, str]:
107
+ if module_path.startswith("."):
108
+ return "local", module_path
109
+ return "third_party", module_path
110
+
111
+
112
+ def _extract_routes(source: str) -> list[RouteInfo]:
113
+ routes: list[RouteInfo] = []
114
+ for m in _EXPRESS_ROUTE_RE.finditer(source):
115
+ routes.append(RouteInfo(
116
+ method=m.group(1).upper(),
117
+ path=m.group(2),
118
+ handler_name="handler",
119
+ ))
120
+ for m in _NESTJS_ROUTE_RE.finditer(source):
121
+ routes.append(RouteInfo(
122
+ method=m.group(1).upper(),
123
+ path=m.group(2) or "/",
124
+ handler_name="handler",
125
+ ))
126
+ return routes
127
+
128
+
129
+ def _analyze_file(file_path: Path, project_root: Path) -> FileResult:
130
+ try:
131
+ source = file_path.read_text(encoding="utf-8", errors="replace")
132
+ except OSError:
133
+ source = ""
134
+
135
+ try:
136
+ rel_path = str(file_path.relative_to(project_root))
137
+ except ValueError:
138
+ rel_path = str(file_path)
139
+
140
+ # Classes / interfaces
141
+ classes: list[ClassInfo] = []
142
+ class_spans: list[tuple[int, int]] = []
143
+ for m in _CLASS_RE.finditer(source):
144
+ name = m.group(1)
145
+ bases_raw = (m.group(2) or "").strip()
146
+ bases = [b.strip() for b in re.split(r",\s*", bases_raw) if b.strip()]
147
+ brace_pos = m.end() - 1
148
+ class_body = _extract_class_body(source, brace_pos)
149
+ class_spans.append((brace_pos, brace_pos + len(class_body) + 2))
150
+ methods = _extract_methods(class_body)
151
+ classes.append(ClassInfo(name=name, bases=bases, methods=methods))
152
+
153
+ def _in_class(pos: int) -> bool:
154
+ return any(start <= pos <= end for start, end in class_spans)
155
+
156
+ # Module-level functions
157
+ functions: list[FunctionInfo] = []
158
+ seen_fns: set[str] = set()
159
+
160
+ for m in _FUNC_DECL_RE.finditer(source):
161
+ if _in_class(m.start()):
162
+ continue
163
+ name = m.group("name")
164
+ if name in seen_fns:
165
+ continue
166
+ seen_fns.add(name)
167
+ functions.append(FunctionInfo(name=name, is_async=bool(m.group("async"))))
168
+
169
+ for m in _ARROW_RE.finditer(source):
170
+ if _in_class(m.start()):
171
+ continue
172
+ name = m.group("name")
173
+ if name in seen_fns:
174
+ continue
175
+ seen_fns.add(name)
176
+ functions.append(FunctionInfo(name=name, is_async=bool(m.group("async"))))
177
+
178
+ # Imports
179
+ third_party: list[str] = []
180
+ local: list[str] = []
181
+
182
+ for m in _IMPORT_FROM_RE.finditer(source):
183
+ kind, path = _classify_import(m.group(1))
184
+ (local if kind == "local" else third_party).append(path)
185
+
186
+ for m in _REQUIRE_RE.finditer(source):
187
+ kind, path = _classify_import(m.group(1))
188
+ (local if kind == "local" else third_party).append(path)
189
+
190
+ imports = ImportInfo(
191
+ third_party=list(dict.fromkeys(third_party)),
192
+ local=list(dict.fromkeys(local)),
193
+ )
194
+
195
+ routes = _extract_routes(source)
196
+
197
+ return FileResult(
198
+ path=rel_path, classes=classes, functions=functions, imports=imports, routes=routes
199
+ )
200
+
201
+
202
+ class JsAnalyzer:
203
+ """Analyzes JavaScript and TypeScript source files using regex-based extraction."""
204
+
205
+ def __init__(self, project_root: Path) -> None:
206
+ self._root = project_root.resolve()
207
+
208
+ def analyze(
209
+ self,
210
+ files: list[Path],
211
+ warnings: list[str] | None = None,
212
+ ) -> AnalysisResult:
213
+ if warnings is None:
214
+ warnings = []
215
+
216
+ file_results: list[FileResult] = []
217
+ for file_path in files:
218
+ try:
219
+ fr = _analyze_file(file_path, self._root)
220
+ file_results.append(fr)
221
+ except Exception as exc: # noqa: BLE001
222
+ warnings.append(f"Failed to analyze {file_path.name}: {exc}")
223
+
224
+ entry_points = [
225
+ fr.path for fr in file_results
226
+ if Path(fr.path).name in _JS_ENTRY_NAMES
227
+ ]
228
+
229
+ return AnalysisResult(
230
+ files_analyzed=file_results,
231
+ entry_points=entry_points,
232
+ warnings=warnings,
233
+ )