zerottmm 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.
zerottmm/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Top‑level package for ttmm.
2
+
3
+ `ttmm` (Time‑to‑Mental‑Model) helps you build a mental model of a Python codebase
4
+ faster. It can index a repository, compute hotspots, navigate static call graphs,
5
+ run dynamic traces and answer natural language questions about your code. The core
6
+ functionality lives in submodules:
7
+
8
+ * `ttmm.index` – parse and index a Python repository
9
+ * `ttmm.store` – SQLite persistence layer
10
+ * `ttmm.metrics` – compute cyclomatic complexity and other metrics
11
+ * `ttmm.gitutils` – git churn calculations
12
+ * `ttmm.trace` – runtime tracing using `sys.settrace`
13
+ * `ttmm.search` – tiny TF‑IDF search over your codebase
14
+ * `ttmm.cli` – command line entry point
15
+
16
+ Importing this package will expose the `__version__` attribute. For most use cases
17
+ you should call into `ttmm.cli` via the `ttmm` command line, or import functions
18
+ from the specific submodules.
19
+ """
20
+
21
+ from importlib.metadata import version, PackageNotFoundError
22
+
23
+ try: # pragma: no cover - during development version metadata may be missing
24
+ __version__ = version(__name__)
25
+ except PackageNotFoundError:
26
+ __version__ = "0.0.0"
27
+
28
+ __all__ = [
29
+ "index",
30
+ "store",
31
+ "metrics",
32
+ "gitutils",
33
+ "trace",
34
+ "search",
35
+ "gitingest",
36
+ "ai_analysis",
37
+ "cli",
38
+ ]
@@ -0,0 +1,149 @@
1
+ """AI-powered code analysis using OpenAI API.
2
+
3
+ This module provides AI-enhanced analysis capabilities for ttmm,
4
+ allowing users to get natural language explanations and insights
5
+ about their codebases using OpenAI's GPT models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Optional
11
+
12
+
13
+ def analyze_code_with_ai(
14
+ api_key: str,
15
+ analysis_type: str,
16
+ hotspots_context: List[str],
17
+ repo_info: Dict,
18
+ custom_prompt: Optional[str] = None,
19
+ ) -> str:
20
+ """Analyze code using OpenAI API.
21
+
22
+ Parameters
23
+ ----------
24
+ api_key : str
25
+ OpenAI API key
26
+ analysis_type : str
27
+ Type of analysis to perform
28
+ hotspots_context : List[str]
29
+ List of hotspot descriptions
30
+ repo_info : Dict
31
+ Repository metadata
32
+ custom_prompt : str, optional
33
+ Custom analysis prompt
34
+
35
+ Returns
36
+ -------
37
+ str
38
+ AI analysis result
39
+ """
40
+ try:
41
+ import openai
42
+ except ImportError:
43
+ return ("❌ **OpenAI library not installed**\n\n"
44
+ "Please install it with: `pip install openai`")
45
+
46
+ # Set up OpenAI client
47
+ client = openai.OpenAI(api_key=api_key)
48
+
49
+ # Prepare context
50
+ repo_context = f"""
51
+ Repository Information:
52
+ - Path: {repo_info.get('path', 'Unknown')}
53
+ - Remote URL: {repo_info.get('remote_url', 'Local repository')}
54
+ - Branch: {repo_info.get('branch', 'Unknown')}
55
+ - Commit: {repo_info.get('commit', 'Unknown')}
56
+
57
+ Top Code Hotspots (high complexity functions):
58
+ {chr(10).join(hotspots_context[:5])}
59
+ """
60
+
61
+ # Define analysis prompts
62
+ analysis_prompts = {
63
+ "Explain hotspots": (
64
+ "Analyze the code hotspots listed above. Explain what makes these functions "
65
+ "complex and suggest potential improvements or areas that might need attention. "
66
+ "Focus on maintainability and potential refactoring opportunities."
67
+ ),
68
+ "Summarize architecture": (
69
+ "Based on the hotspots and repository information, provide a high-level "
70
+ "architectural summary of this codebase. Identify the main components, "
71
+ "patterns, and overall structure."
72
+ ),
73
+ "Identify design patterns": (
74
+ "Analyze the code hotspots and identify any design patterns being used. "
75
+ "Comment on the appropriateness of these patterns and suggest alternatives "
76
+ "if beneficial."
77
+ ),
78
+ "Find potential issues": (
79
+ "Review the code hotspots for potential issues like performance bottlenecks, "
80
+ "security concerns, maintainability problems, or technical debt. Provide "
81
+ "specific recommendations."
82
+ ),
83
+ "Custom analysis": custom_prompt or "Provide a general analysis of the codebase.",
84
+ }
85
+
86
+ prompt = analysis_prompts.get(analysis_type, analysis_prompts["Custom analysis"])
87
+
88
+ try:
89
+ response = client.chat.completions.create(
90
+ model="gpt-3.5-turbo",
91
+ messages=[
92
+ {
93
+ "role": "system",
94
+ "content": (
95
+ "You are a senior software engineer helping to analyze a Python "
96
+ "codebase. Provide clear, actionable insights based on the code "
97
+ "metrics and hotspots provided. Be concise but thorough."
98
+ )
99
+ },
100
+ {
101
+ "role": "user",
102
+ "content": f"{repo_context}\n\nAnalysis Request: {prompt}"
103
+ }
104
+ ],
105
+ max_tokens=1000,
106
+ temperature=0.3,
107
+ )
108
+
109
+ return response.choices[0].message.content or "No analysis generated."
110
+
111
+ except openai.OpenAIError as e:
112
+ return f"❌ **OpenAI API Error**: {str(e)}"
113
+ except Exception as e:
114
+ return f"❌ **Analysis Error**: {str(e)}"
115
+
116
+
117
+ def test_openai_connection(api_key: str) -> tuple[bool, str]:
118
+ """Test if OpenAI API key is valid.
119
+
120
+ Parameters
121
+ ----------
122
+ api_key : str
123
+ OpenAI API key to test
124
+
125
+ Returns
126
+ -------
127
+ tuple[bool, str]
128
+ (success, message)
129
+ """
130
+ try:
131
+ import openai
132
+ except ImportError:
133
+ return False, "OpenAI library not installed"
134
+
135
+ try:
136
+ client = openai.OpenAI(api_key=api_key)
137
+ # Make a minimal API call to test the key
138
+ client.chat.completions.create(
139
+ model="gpt-3.5-turbo",
140
+ messages=[{"role": "user", "content": "Hello"}],
141
+ max_tokens=5
142
+ )
143
+ return True, "API key is valid"
144
+ except openai.AuthenticationError:
145
+ return False, "Invalid API key"
146
+ except openai.OpenAIError as e:
147
+ return False, f"OpenAI API error: {str(e)}"
148
+ except Exception as e:
149
+ return False, f"Connection error: {str(e)}"
zerottmm/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ """Command line interface for ttmm.
2
+
3
+ This module exposes a ``main`` function that can be installed as a
4
+ console script entry point. It supports subcommands for indexing,
5
+ listing hotspots, resolving callers/callees, running traces and
6
+ answering questions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from typing import List
14
+
15
+ from . import index, store, trace, search, gitingest
16
+
17
+
18
+ def do_index(args: argparse.Namespace) -> None:
19
+ repo_path = _resolve_repo_path(args.path)
20
+ if repo_path is None:
21
+ print(f"Failed to fetch repository: {args.path}")
22
+ sys.exit(1)
23
+
24
+ try:
25
+ index.index_repo(repo_path)
26
+ print(f"Indexed repository {args.path}")
27
+ finally:
28
+ # Clean up temp directory if it was a remote fetch
29
+ if repo_path != args.path and repo_path.startswith('/tmp'):
30
+ gitingest.cleanup_temp_repo(repo_path)
31
+
32
+
33
+ def do_hotspots(args: argparse.Namespace) -> None:
34
+ repo_path = _resolve_repo_path(args.path, temp_ok=False)
35
+ if repo_path is None:
36
+ print(f"Repository not found or not indexed: {args.path}")
37
+ sys.exit(1)
38
+
39
+ conn = store.connect(repo_path)
40
+ try:
41
+ rows = store.get_hotspots(conn, limit=args.limit)
42
+ if not rows:
43
+ print("No hotspot data found. Did you index the repository?")
44
+ return
45
+ for row in rows:
46
+ score = row["complexity"] * (1.0 + (row["churn"] or 0) ** 0.5)
47
+ complexity_info = f"complexity={row['complexity']:.1f}"
48
+ churn_info = f"churn={row['churn']:.3f}, score={score:.2f}"
49
+ print(
50
+ f"{row['qualname']} ({row['file_path']}:{row['lineno']}) – {complexity_info}, "
51
+ f"{churn_info}"
52
+ )
53
+ finally:
54
+ store.close(conn)
55
+
56
+
57
+ def do_callers(args: argparse.Namespace) -> None:
58
+ repo_path = _resolve_repo_path(args.path, temp_ok=False)
59
+ if repo_path is None:
60
+ print(f"Repository not found or not indexed: {args.path}")
61
+ sys.exit(1)
62
+
63
+ conn = store.connect(repo_path)
64
+ try:
65
+ sid = store.resolve_symbol(conn, args.symbol)
66
+ if sid is None:
67
+ print(f"Symbol '{args.symbol}' not found")
68
+ return
69
+ callers = store.get_callers(conn, sid)
70
+ if not callers:
71
+ print("No callers found.")
72
+ else:
73
+ for qualname, path in callers:
74
+ print(f"{qualname} ({path})")
75
+ finally:
76
+ store.close(conn)
77
+
78
+
79
+ def do_callees(args: argparse.Namespace) -> None:
80
+ repo_path = _resolve_repo_path(args.path, temp_ok=False)
81
+ if repo_path is None:
82
+ print(f"Repository not found or not indexed: {args.path}")
83
+ sys.exit(1)
84
+
85
+ conn = store.connect(repo_path)
86
+ try:
87
+ sid = store.resolve_symbol(conn, args.symbol)
88
+ if sid is None:
89
+ print(f"Symbol '{args.symbol}' not found")
90
+ return
91
+ callees = store.get_callees(conn, sid)
92
+ if not callees:
93
+ print("No callees found.")
94
+ else:
95
+ for name, path, unresolved in callees:
96
+ suffix = " (unresolved)" if unresolved else ""
97
+ loc = f" ({path})" if path else ""
98
+ print(f"{name}{loc}{suffix}")
99
+ finally:
100
+ store.close(conn)
101
+
102
+
103
+ def do_trace(args: argparse.Namespace) -> None:
104
+ repo_path = _resolve_repo_path(args.path, temp_ok=False)
105
+ if repo_path is None:
106
+ print(f"Repository not found or not indexed: {args.path}")
107
+ sys.exit(1)
108
+
109
+ # Flatten args after '--'
110
+ target_args: List[str] = args.args if hasattr(args, "args") else []
111
+ trace.run_tracing(repo_path, module=args.module, script=args.script, args=target_args)
112
+ print("Trace completed")
113
+
114
+
115
+ def do_answer(args: argparse.Namespace) -> None:
116
+ repo_path = _resolve_repo_path(args.path, temp_ok=False)
117
+ if repo_path is None:
118
+ print(f"Repository not found or not indexed: {args.path}")
119
+ sys.exit(1)
120
+
121
+ results = search.answer_question(repo_path, args.question, top=args.limit, include_scores=True)
122
+ if not results:
123
+ print("No relevant symbols found.")
124
+ else:
125
+ for qualname, path, lineno, score in results:
126
+ print(f"{qualname} ({path}:{lineno}) – score={score:.2f}")
127
+
128
+
129
+ def _resolve_repo_path(path_or_url: str, temp_ok: bool = True) -> str | None:
130
+ """Resolve a path or URL to a local repository path.
131
+
132
+ For URLs, fetches the repository. For local paths, validates existence.
133
+
134
+ Parameters
135
+ ----------
136
+ path_or_url : str
137
+ Local path, Git URL, or GitIngest URL
138
+ temp_ok : bool
139
+ Whether to allow temporary directory creation for remote repos
140
+
141
+ Returns
142
+ -------
143
+ str or None
144
+ Local path to repository, or None if resolution failed
145
+ """
146
+ import os
147
+
148
+ # If it's a local path that exists, return it
149
+ if os.path.exists(path_or_url):
150
+ return os.path.abspath(path_or_url)
151
+
152
+ # If temp directories are not allowed, only work with local paths
153
+ if not temp_ok:
154
+ return None
155
+
156
+ # Try to fetch as a remote repository
157
+ return gitingest.fetch_repository(path_or_url)
158
+
159
+
160
+ def main(argv: List[str] | None = None) -> None:
161
+ parser = argparse.ArgumentParser(
162
+ prog="ttmm", description="Time‑to‑Mental‑Model code reading assistant"
163
+ )
164
+ sub = parser.add_subparsers(dest="command", required=True)
165
+ # index
166
+ p_index = sub.add_parser("index", help="Index a Python repository")
167
+ p_index.add_argument(
168
+ "path", help="Path, Git URL, or GitIngest URL of repository"
169
+ )
170
+ p_index.set_defaults(func=do_index)
171
+ # hotspots
172
+ p_hot = sub.add_parser("hotspots", help="Show hottest functions/methods")
173
+ p_hot.add_argument("path", help="Path to repository")
174
+ p_hot.add_argument("--limit", type=int, default=10, help="Number of results to show")
175
+ p_hot.set_defaults(func=do_hotspots)
176
+ # callers
177
+ p_callers = sub.add_parser("callers", help="Show functions that call the given symbol")
178
+ p_callers.add_argument("path", help="Path to repository")
179
+ p_callers.add_argument("symbol", help="Fully qualified or simple symbol name")
180
+ p_callers.set_defaults(func=do_callers)
181
+ # callees
182
+ p_callees = sub.add_parser("callees", help="Show functions called by the given symbol")
183
+ p_callees.add_argument("path", help="Path to repository")
184
+ p_callees.add_argument("symbol", help="Fully qualified or simple symbol name")
185
+ p_callees.set_defaults(func=do_callees)
186
+ # trace
187
+ p_trace = sub.add_parser(
188
+ "trace", help="Trace runtime execution of a module function or script"
189
+ )
190
+ p_trace.add_argument("path", help="Path to repository")
191
+ group = p_trace.add_mutually_exclusive_group(required=True)
192
+ group.add_argument(
193
+ "--module", help="Module entry point in form pkg.module:func or pkg.module to run"
194
+ )
195
+ group.add_argument("--script", help="Relative path to a Python script to run")
196
+ p_trace.add_argument(
197
+ "args", nargs=argparse.REMAINDER,
198
+ help="Arguments passed to the module function or script"
199
+ )
200
+ p_trace.set_defaults(func=do_trace)
201
+ # answer
202
+ p_answer = sub.add_parser("answer", help="Answer a question about the codebase")
203
+ p_answer.add_argument("path", help="Path to repository")
204
+ p_answer.add_argument("question", help="Natural language question or keywords")
205
+ p_answer.add_argument("--limit", type=int, default=5, help="Number of answers to return")
206
+ p_answer.set_defaults(func=do_answer)
207
+ args = parser.parse_args(argv)
208
+ args.func(args)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
zerottmm/gitingest.py ADDED
@@ -0,0 +1,219 @@
1
+ """GitIngest integration for ttmm.
2
+
3
+ This module handles fetching repositories from GitIngest URLs and other
4
+ remote sources. It parses various Git hosting URLs (GitHub, GitLab, etc.)
5
+ and provides functionality to download and extract repositories for analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+ import shutil
14
+ import subprocess
15
+ from typing import Optional, Tuple
16
+ from urllib.parse import urlparse, parse_qs
17
+
18
+
19
+ def _is_git_url(url: str) -> bool:
20
+ """Check if a string looks like a Git repository URL."""
21
+ git_patterns = [
22
+ r'https?://github\.com/[^/]+/[^/]+',
23
+ r'https?://gitlab\.com/[^/]+/[^/]+',
24
+ r'https?://bitbucket\.org/[^/]+/[^/]+',
25
+ r'git@github\.com:[^/]+/[^/]+\.git',
26
+ r'git@gitlab\.com:[^/]+/[^/]+\.git',
27
+ r'https?://.*\.git$',
28
+ ]
29
+ return any(re.match(pattern, url) for pattern in git_patterns)
30
+
31
+
32
+ def _parse_gitingest_url(url: str) -> Optional[Tuple[str, Optional[str], Optional[str]]]:
33
+ """Parse a GitIngest URL to extract repository info.
34
+
35
+ Returns (repo_url, branch, subpath) or None if not a GitIngest URL.
36
+ """
37
+ if 'gitingest.com' not in url:
38
+ return None
39
+
40
+ parsed = urlparse(url)
41
+ if not parsed.query:
42
+ return None
43
+
44
+ query_params = parse_qs(parsed.query)
45
+ repo_url = query_params.get('url', [None])[0]
46
+ branch = query_params.get('branch', [None])[0]
47
+ subpath = query_params.get('subpath', [None])[0]
48
+
49
+ if repo_url:
50
+ return repo_url, branch, subpath
51
+ return None
52
+
53
+
54
+ def _normalize_repo_url(url: str) -> str:
55
+ """Normalize a repository URL for cloning."""
56
+ # Convert SSH URLs to HTTPS for easier cloning
57
+ if url.startswith('git@'):
58
+ # git@github.com:owner/repo.git -> https://github.com/owner/repo.git
59
+ ssh_pattern = r'git@([^:]+):([^/]+)/([^/]+)(?:\.git)?'
60
+ match = re.match(ssh_pattern, url)
61
+ if match:
62
+ host, owner, repo = match.groups()
63
+ return f'https://{host}/{owner}/{repo}.git'
64
+
65
+ # Ensure .git suffix for HTTPS URLs
66
+ if not url.endswith('.git') and _is_git_url(url):
67
+ return url + '.git'
68
+
69
+ return url
70
+
71
+
72
+ def _clone_repository(
73
+ repo_url: str, target_dir: str, branch: Optional[str] = None, shallow: bool = True
74
+ ) -> bool:
75
+ """Clone a repository to the target directory.
76
+
77
+ Returns True if successful, False otherwise.
78
+ """
79
+ try:
80
+ cmd = ['git', 'clone']
81
+ if shallow:
82
+ cmd.extend(['--depth', '1'])
83
+ if branch:
84
+ cmd.extend(['--branch', branch])
85
+ cmd.extend([repo_url, target_dir])
86
+
87
+ result = subprocess.run(
88
+ cmd, capture_output=True, text=True, timeout=300
89
+ )
90
+ return result.returncode == 0
91
+ except (
92
+ subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError
93
+ ):
94
+ return False
95
+
96
+
97
+ def fetch_repository(url_or_path: str, target_dir: Optional[str] = None) -> Optional[str]:
98
+ """Fetch a repository from a URL or return a local path.
99
+
100
+ Handles GitIngest URLs, direct Git URLs, and local paths.
101
+
102
+ Parameters
103
+ ----------
104
+ url_or_path : str
105
+ GitIngest URL, Git repository URL, or local file path.
106
+ target_dir : str, optional
107
+ Directory to clone into. If None, uses a temporary directory.
108
+
109
+ Returns
110
+ -------
111
+ str or None
112
+ Path to the repository directory, or None if fetch failed.
113
+ """
114
+ # If it's a local path, return as-is
115
+ if os.path.exists(url_or_path):
116
+ return os.path.abspath(url_or_path)
117
+
118
+ # Parse GitIngest URL
119
+ gitingest_info = _parse_gitingest_url(url_or_path)
120
+ if gitingest_info:
121
+ repo_url, branch, subpath = gitingest_info
122
+ if not repo_url:
123
+ return None
124
+ else:
125
+ # Check if it's a direct Git URL
126
+ if _is_git_url(url_or_path):
127
+ repo_url = url_or_path
128
+ branch = None
129
+ subpath = None
130
+ else:
131
+ return None
132
+
133
+ # Normalize the repository URL
134
+ repo_url = _normalize_repo_url(repo_url)
135
+
136
+ # Create target directory
137
+ if target_dir is None:
138
+ target_dir = tempfile.mkdtemp(prefix='ttmm_repo_')
139
+ else:
140
+ os.makedirs(target_dir, exist_ok=True)
141
+
142
+ # Clone the repository
143
+ clone_success = _clone_repository(repo_url, target_dir, branch)
144
+ if not clone_success:
145
+ if target_dir.startswith(tempfile.gettempdir()):
146
+ shutil.rmtree(target_dir, ignore_errors=True)
147
+ return None
148
+
149
+ # Handle subpath if specified
150
+ if subpath:
151
+ subpath_full = os.path.join(target_dir, subpath)
152
+ if os.path.exists(subpath_full):
153
+ return subpath_full
154
+
155
+ return target_dir
156
+
157
+
158
+ def cleanup_temp_repo(repo_path: str) -> None:
159
+ """Clean up a temporary repository directory."""
160
+ if repo_path and repo_path.startswith(tempfile.gettempdir()):
161
+ shutil.rmtree(repo_path, ignore_errors=True)
162
+
163
+
164
+ def get_repo_info(repo_path: str) -> dict:
165
+ """Extract basic information about a repository.
166
+
167
+ Returns a dictionary with repository metadata.
168
+ """
169
+ info = {
170
+ 'path': repo_path,
171
+ 'is_git': False,
172
+ 'remote_url': None,
173
+ 'branch': None,
174
+ 'commit': None,
175
+ }
176
+
177
+ # Check if it's a git repository
178
+ git_dir = os.path.join(repo_path, '.git')
179
+ if os.path.exists(git_dir):
180
+ info['is_git'] = True
181
+
182
+ try:
183
+ # Get remote URL
184
+ result = subprocess.run(
185
+ ['git', 'remote', 'get-url', 'origin'],
186
+ cwd=repo_path,
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=10
190
+ )
191
+ if result.returncode == 0:
192
+ info['remote_url'] = result.stdout.strip()
193
+
194
+ # Get current branch
195
+ result = subprocess.run(
196
+ ['git', 'branch', '--show-current'],
197
+ cwd=repo_path,
198
+ capture_output=True,
199
+ text=True,
200
+ timeout=10
201
+ )
202
+ if result.returncode == 0:
203
+ info['branch'] = result.stdout.strip()
204
+
205
+ # Get current commit
206
+ result = subprocess.run(
207
+ ['git', 'rev-parse', 'HEAD'],
208
+ cwd=repo_path,
209
+ capture_output=True,
210
+ text=True,
211
+ timeout=10
212
+ )
213
+ if result.returncode == 0:
214
+ info['commit'] = result.stdout.strip()[:8]
215
+
216
+ except (subprocess.SubprocessError, FileNotFoundError):
217
+ pass
218
+
219
+ return info