athena-code 0.0.14__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.

Potentially problematic release.


This version of athena-code might be problematic. Click here for more details.

athena/entity_path.py ADDED
@@ -0,0 +1,146 @@
1
+ """Entity path parsing and resolution.
2
+
3
+ Entity path format: dir/package/module:class.method
4
+ Examples:
5
+ - src/athena/cli.py:app
6
+ - src/athena/parsers/python_parser.py:PythonParser.parse_athena_tag
7
+ - models.py:Entity
8
+ - src/athena
9
+ """
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass
17
+ class EntityPath:
18
+ """Represents a parsed entity path.
19
+
20
+ Attributes:
21
+ file_path: Path to the file (without entity specification)
22
+ entity_name: Name of the entity within the file (class.method or function)
23
+ None for module-level or package-level operations
24
+ """
25
+
26
+ file_path: str
27
+ entity_name: str | None = None
28
+
29
+ @property
30
+ def is_package(self) -> bool:
31
+ """Check if this path represents a package (directory)."""
32
+ return not self.file_path.endswith(".py") and self.entity_name is None
33
+
34
+ @property
35
+ def is_module(self) -> bool:
36
+ """Check if this path represents a module (file without entity)."""
37
+ return self.file_path.endswith(".py") and self.entity_name is None
38
+
39
+ @property
40
+ def is_class(self) -> bool:
41
+ """Check if this path represents a class (no dot in entity name)."""
42
+ return self.entity_name is not None and "." not in self.entity_name
43
+
44
+ @property
45
+ def is_method(self) -> bool:
46
+ """Check if this path represents a method (dot in entity name)."""
47
+ return self.entity_name is not None and "." in self.entity_name
48
+
49
+ @property
50
+ def class_name(self) -> str | None:
51
+ """Extract class name if this is a method path."""
52
+ if self.is_method:
53
+ return self.entity_name.split(".")[0]
54
+ return None
55
+
56
+ @property
57
+ def method_name(self) -> str | None:
58
+ """Extract method name if this is a method path."""
59
+ if self.is_method:
60
+ return self.entity_name.split(".")[1]
61
+ return None
62
+
63
+
64
+ def parse_entity_path(path: str) -> EntityPath:
65
+ """Parse an entity path string into components.
66
+
67
+ Format: [directory/]file.py[:entity[.subentity]]
68
+
69
+ Args:
70
+ path: Entity path string to parse
71
+
72
+ Returns:
73
+ EntityPath object with parsed components
74
+
75
+ Raises:
76
+ ValueError: If path format is invalid
77
+
78
+ Examples:
79
+ >>> parse_entity_path("src/foo/bar.py:Baz.bax")
80
+ EntityPath(file_path='src/foo/bar.py', entity_name='Baz.bax')
81
+
82
+ >>> parse_entity_path("models.py:Entity")
83
+ EntityPath(file_path='models.py', entity_name='Entity')
84
+
85
+ >>> parse_entity_path("src/athena/cli.py")
86
+ EntityPath(file_path='src/athena/cli.py', entity_name=None)
87
+
88
+ >>> parse_entity_path("src/athena")
89
+ EntityPath(file_path='src/athena', entity_name=None)
90
+ """
91
+ if not path or not path.strip():
92
+ raise ValueError("Entity path cannot be empty")
93
+
94
+ # Split on colon to separate file path from entity name
95
+ if ":" in path:
96
+ file_path, entity_name = path.split(":", 1)
97
+ entity_name = entity_name.strip()
98
+ if not entity_name:
99
+ entity_name = None
100
+ else:
101
+ file_path = path
102
+ entity_name = None
103
+
104
+ file_path = file_path.strip()
105
+
106
+ if not file_path:
107
+ raise ValueError("File path component cannot be empty")
108
+
109
+ return EntityPath(file_path=file_path, entity_name=entity_name)
110
+
111
+
112
+ def resolve_entity_path(entity_path: EntityPath, repo_root: Path) -> Path | None:
113
+ """Resolve an EntityPath to an actual file system path.
114
+
115
+ Args:
116
+ entity_path: Parsed entity path
117
+ repo_root: Root directory of the repository
118
+
119
+ Returns:
120
+ Resolved Path object if file/directory exists, None otherwise
121
+ """
122
+ # Construct full path
123
+ full_path = repo_root / entity_path.file_path
124
+
125
+ # If it's meant to be a package, check if it's a valid directory
126
+ if entity_path.is_package:
127
+ # Check if the directory exists
128
+ if not full_path.is_dir():
129
+ return None
130
+
131
+ # For the repository root (.), we don't require __init__.py
132
+ # For other directories, require __init__.py to be a valid package
133
+ if entity_path.file_path == ".":
134
+ return full_path
135
+
136
+ init_path = full_path / "__init__.py"
137
+ if init_path.exists():
138
+ # Return the directory path, not __init__.py
139
+ return full_path
140
+ return None
141
+
142
+ # For files, check if path exists
143
+ if full_path.exists():
144
+ return full_path
145
+
146
+ return None
athena/hashing.py ADDED
@@ -0,0 +1,156 @@
1
+ """Hash generation infrastructure for code entities using tree-sitter AST."""
2
+
3
+ import hashlib
4
+ import re
5
+
6
+
7
+ def _is_docstring_node(node, parent_node) -> bool:
8
+ """Check if a node is a docstring (first statement in body that's a string).
9
+
10
+ Args:
11
+ node: The node to check
12
+ parent_node: The parent node (should be a block)
13
+
14
+ Returns:
15
+ True if this is a docstring node
16
+ """
17
+ # Docstrings are expression_statements containing a string
18
+ if node.type != "expression_statement":
19
+ return False
20
+
21
+ # Must be the first child of a block (body)
22
+ if parent_node is None or parent_node.type != "block":
23
+ return False
24
+
25
+ # Check if this is the first child of the block
26
+ if parent_node.children and parent_node.children[0] == node:
27
+ # Check if it contains a string
28
+ for child in node.children:
29
+ if child.type == "string":
30
+ return True
31
+
32
+ return False
33
+
34
+
35
+ def serialize_ast_node(node, source_code: str) -> str:
36
+ """Serialize a tree-sitter AST node to a stable string representation.
37
+
38
+ This serialization includes node types and names, which forms the basis for
39
+ generating content hashes. The serialization is designed to be stable across
40
+ whitespace changes but sensitive to semantic changes.
41
+
42
+ Docstrings are excluded from serialization to ensure hash stability when
43
+ @athena tags are added or updated.
44
+
45
+ Args:
46
+ node: Tree-sitter AST node to serialize
47
+ source_code: Source code string for extracting identifiers
48
+
49
+ Returns:
50
+ Serialized string representation of the AST structure
51
+ """
52
+ parts = []
53
+
54
+ def serialize(n, parent=None, depth: int = 0):
55
+ """Recursively serialize the node and its children."""
56
+ # Skip docstring nodes
57
+ if _is_docstring_node(n, parent):
58
+ return
59
+
60
+ # For nodes with meaningful text content, include the text
61
+ if n.type in ("identifier", "integer", "float", "string"):
62
+ text = source_code.encode("utf8")[n.start_byte : n.end_byte].decode("utf8")
63
+ parts.append(f"{n.type}:{text}")
64
+ else:
65
+ # Add node type
66
+ parts.append(f"{n.type}")
67
+
68
+ # Recursively serialize children
69
+ for child in n.children:
70
+ serialize(child, n, depth + 1)
71
+
72
+ serialize(node)
73
+ return "|".join(parts)
74
+
75
+
76
+ def compute_hash(content: str) -> str:
77
+ """Compute SHA-256 hash and truncate to 12 hex characters.
78
+
79
+ Args:
80
+ content: Content string to hash
81
+
82
+ Returns:
83
+ 12-character hex hash string
84
+ """
85
+ hash_obj = hashlib.sha256(content.encode("utf8"))
86
+ return hash_obj.hexdigest()[:12]
87
+
88
+
89
+ def compute_function_hash(node, source_code: str) -> str:
90
+ """Compute hash for a function (signature + body).
91
+
92
+ Args:
93
+ node: Tree-sitter function_definition node
94
+ source_code: Source code string
95
+
96
+ Returns:
97
+ 12-character hex hash
98
+ """
99
+ # Serialize the entire function node (includes signature and body)
100
+ serialization = serialize_ast_node(node, source_code)
101
+ return compute_hash(serialization)
102
+
103
+
104
+ def compute_class_hash(node, source_code: str) -> str:
105
+ """Compute hash for a class (declaration + method signatures + implementations).
106
+
107
+ Args:
108
+ node: Tree-sitter class_definition node
109
+ source_code: Source code string
110
+
111
+ Returns:
112
+ 12-character hex hash
113
+ """
114
+ # Serialize the entire class node (includes declaration, methods, etc.)
115
+ serialization = serialize_ast_node(node, source_code)
116
+ return compute_hash(serialization)
117
+
118
+
119
+ def compute_module_hash(entities_docstrings: list[str]) -> str:
120
+ """Compute hash for a module based on non-whitespace from entity docstrings.
121
+
122
+ Args:
123
+ entities_docstrings: List of docstring contents from module entities
124
+
125
+ Returns:
126
+ 12-character hex hash
127
+ """
128
+ # Concatenate all docstrings with non-whitespace characters only
129
+ combined = ""
130
+ for docstring in entities_docstrings:
131
+ if docstring:
132
+ # Remove all whitespace
133
+ no_whitespace = re.sub(r"\s+", "", docstring)
134
+ combined += no_whitespace
135
+
136
+ return compute_hash(combined)
137
+
138
+
139
+ def compute_package_hash(module_docstrings: list[str]) -> str:
140
+ """Compute hash for a package based on non-whitespace from module docstrings.
141
+
142
+ Args:
143
+ module_docstrings: List of module docstrings from package
144
+
145
+ Returns:
146
+ 12-character hex hash
147
+ """
148
+ # Same logic as module hash - concatenate non-whitespace
149
+ combined = ""
150
+ for docstring in module_docstrings:
151
+ if docstring:
152
+ # Remove all whitespace
153
+ no_whitespace = re.sub(r"\s+", "", docstring)
154
+ combined += no_whitespace
155
+
156
+ return compute_hash(combined)
athena/info.py ADDED
@@ -0,0 +1,84 @@
1
+ from pathlib import Path
2
+
3
+ from athena.models import EntityInfo, PackageInfo
4
+ from athena.parsers import get_parser_for_file
5
+ from athena.repository import find_repository_root, get_relative_path
6
+
7
+
8
+ def get_entity_info(
9
+ file_path: str,
10
+ entity_name: str | None = None,
11
+ root: Path | None = None
12
+ ) -> EntityInfo | None:
13
+ """Get detailed information about an entity in a file or package.
14
+
15
+ Args:
16
+ file_path: Path to file or directory (can be absolute or relative to repo root)
17
+ entity_name: Name of entity, or None for module/package-level info
18
+ root: Repository root (auto-detected if None)
19
+
20
+ Returns:
21
+ EntityInfo object, or None if file/entity not found
22
+
23
+ Raises:
24
+ FileNotFoundError: If file/directory doesn't exist
25
+ ValueError: If file type not supported or directory missing __init__.py
26
+ """
27
+ # Auto-detect repository root if not provided
28
+ if root is None:
29
+ root = find_repository_root(Path.cwd())
30
+
31
+ # Resolve file path
32
+ file_path_obj = Path(file_path)
33
+ if not file_path_obj.is_absolute():
34
+ file_path_obj = root / file_path_obj
35
+
36
+ # Check path exists
37
+ if not file_path_obj.exists():
38
+ raise FileNotFoundError(f"Path not found: {file_path}")
39
+
40
+ # Handle directory (package) case
41
+ if file_path_obj.is_dir():
42
+ if entity_name is not None:
43
+ raise ValueError(f"Cannot specify entity name for package: {file_path}")
44
+
45
+ # Look for __init__.py
46
+ init_file = file_path_obj / "__init__.py"
47
+ if not init_file.exists():
48
+ raise ValueError(f"Package missing __init__.py: {file_path}")
49
+
50
+ # Get parser and extract module docstring from __init__.py
51
+ parser = get_parser_for_file(init_file)
52
+ if parser is None:
53
+ raise ValueError(f"Cannot parse __init__.py in: {file_path}")
54
+
55
+ source_code = init_file.read_text()
56
+ # Get relative path for the directory
57
+ relative_path = get_relative_path(file_path_obj, root)
58
+
59
+ # Extract module-level info from __init__.py
60
+ module_info = parser.extract_entity_info(source_code, str(init_file), None)
61
+
62
+ # Convert to PackageInfo
63
+ if module_info is not None:
64
+ return PackageInfo(
65
+ path=relative_path,
66
+ summary=module_info.summary if hasattr(module_info, 'summary') else None
67
+ )
68
+ else:
69
+ return PackageInfo(path=relative_path, summary=None)
70
+
71
+ # Handle file case
72
+ # Get parser for file
73
+ parser = get_parser_for_file(file_path_obj)
74
+ if parser is None:
75
+ raise ValueError(f"Unsupported file type: {file_path}")
76
+
77
+ # Read source code
78
+ source_code = file_path_obj.read_text()
79
+
80
+ # Get relative path for EntityInfo.path
81
+ relative_path = get_relative_path(file_path_obj, root)
82
+
83
+ # Call parser to extract entity info
84
+ return parser.extract_entity_info(source_code, relative_path, entity_name)
athena/locate.py ADDED
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+
3
+ from athena.models import Entity
4
+ from athena.parsers import get_parser_for_file
5
+ from athena.repository import find_python_files, find_repository_root, get_relative_path
6
+
7
+
8
+ def locate_entity(name: str, root: Path | None = None) -> list[Entity]:
9
+ """Locate all entities with the given name in the repository.
10
+
11
+ For methods, the search matches both the full qualified name (ClassName.method_name)
12
+ and the short method name (method_name).
13
+
14
+ Args:
15
+ name: The entity name to search for
16
+ root: Repository root (defaults to auto-detected root)
17
+
18
+ Returns:
19
+ List of Entity objects matching the given name
20
+ """
21
+ if root is None:
22
+ root = find_repository_root()
23
+
24
+ entities = []
25
+
26
+ for file_path in find_python_files(root):
27
+ parser = get_parser_for_file(file_path)
28
+ if parser is None:
29
+ continue
30
+
31
+ try:
32
+ source_code = file_path.read_text(encoding="utf-8")
33
+ relative_path = get_relative_path(file_path, root)
34
+
35
+ file_entities = parser.extract_entities(source_code, relative_path)
36
+
37
+ # Filter entities by name
38
+ for entity in file_entities:
39
+ # Exact match
40
+ if entity.name == name:
41
+ entities.append(entity)
42
+ # For methods, also match the short name (without class prefix)
43
+ elif entity.kind == "method" and "." in entity.name:
44
+ method_name = entity.name.split(".", 1)[1]
45
+ if method_name == name:
46
+ entities.append(entity)
47
+ except Exception:
48
+ # Skip files that can't be read or parsed
49
+ # This allows the scan to continue even if some files fail
50
+ continue
51
+
52
+ return entities
athena/mcp_config.py ADDED
@@ -0,0 +1,103 @@
1
+ """MCP configuration management for Claude Code integration."""
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ from pathlib import Path
7
+
8
+
9
+ def get_claude_config_path() -> Path:
10
+ """Get the Claude Code configuration file path for the current OS.
11
+
12
+ Returns:
13
+ Path to claude_desktop_config.json
14
+
15
+ Raises:
16
+ RuntimeError: If OS is not supported
17
+ """
18
+ system = platform.system()
19
+
20
+ if system == "Darwin": # macOS
21
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
22
+ elif system == "Linux":
23
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
24
+ elif system == "Windows":
25
+ return Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
26
+ else:
27
+ raise RuntimeError(f"Unsupported operating system: {system}")
28
+
29
+
30
+ def install_mcp_config() -> tuple[bool, str]:
31
+ """Install MCP server configuration for Claude Code.
32
+
33
+ Returns:
34
+ Tuple of (success, message)
35
+ """
36
+ try:
37
+ config_path = get_claude_config_path()
38
+
39
+ # Ensure config directory exists
40
+ config_path.parent.mkdir(parents=True, exist_ok=True)
41
+
42
+ # Load existing config or create new one
43
+ if config_path.exists():
44
+ with open(config_path, "r", encoding="utf-8") as f:
45
+ config = json.load(f)
46
+ else:
47
+ config = {}
48
+
49
+ # Ensure mcpServers section exists
50
+ if "mcpServers" not in config:
51
+ config["mcpServers"] = {}
52
+
53
+ # Check if ack is already configured
54
+ if "ack" in config["mcpServers"]:
55
+ return (False, "MCP server already configured")
56
+
57
+ # Add ack MCP server configuration
58
+ config["mcpServers"]["ack"] = {
59
+ "command": "ack",
60
+ "args": ["mcp-server"]
61
+ }
62
+
63
+ # Write updated config
64
+ with open(config_path, "w", encoding="utf-8") as f:
65
+ json.dump(config, f, indent=2)
66
+
67
+ return (True, f"MCP server configured at {config_path}")
68
+
69
+ except Exception as e:
70
+ return (False, f"Failed to install MCP config: {e}")
71
+
72
+
73
+ def uninstall_mcp_config() -> tuple[bool, str]:
74
+ """Remove MCP server configuration from Claude Code.
75
+
76
+ Returns:
77
+ Tuple of (success, message)
78
+ """
79
+ try:
80
+ config_path = get_claude_config_path()
81
+
82
+ if not config_path.exists():
83
+ return (False, "Claude config file not found")
84
+
85
+ # Load existing config
86
+ with open(config_path, "r", encoding="utf-8") as f:
87
+ config = json.load(f)
88
+
89
+ # Check if ack is configured
90
+ if "mcpServers" not in config or "ack" not in config["mcpServers"]:
91
+ return (False, "MCP server not configured")
92
+
93
+ # Remove ack configuration
94
+ del config["mcpServers"]["ack"]
95
+
96
+ # Write updated config
97
+ with open(config_path, "w", encoding="utf-8") as f:
98
+ json.dump(config, f, indent=2)
99
+
100
+ return (True, f"MCP server removed from {config_path}")
101
+
102
+ except Exception as e:
103
+ return (False, f"Failed to uninstall MCP config: {e}")