janito 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.
janito/workspace.py ADDED
@@ -0,0 +1,169 @@
1
+ from pathlib import Path
2
+ from typing import List, Dict
3
+
4
+ class Workspace:
5
+ def __init__(self, base_path: Path = None):
6
+ self.base_path = base_path or Path().absolute()
7
+ self.default_exclude = [".janito", "__pycache__", ".git", ".janito/history"] # Added .janito/history
8
+ self.default_patterns = ["*.py", "*.txt", "*.md", "*.toml", "**/.gitignore"]
9
+ self.history_file = self.base_path / ".janito" / "history"
10
+ # Create .janito directory if it doesn't exist
11
+ (self.base_path / ".janito").mkdir(exist_ok=True)
12
+
13
+ def generate_file_structure(self, pattern: str = None, exclude_patterns: List[str] = None) -> Dict:
14
+ """Generate a tree structure of files in the workspace directory."""
15
+ exclude_patterns = exclude_patterns or self.default_exclude
16
+ patterns = [pattern] if pattern else self.default_patterns
17
+
18
+ tree = {}
19
+ try:
20
+ base_path = self.base_path.resolve()
21
+
22
+ for pattern in patterns:
23
+ for file in sorted(base_path.rglob(pattern)):
24
+ try:
25
+ import fnmatch
26
+
27
+ if any(fnmatch.fnmatch(str(file), pat) for pat in exclude_patterns):
28
+ continue
29
+
30
+ try:
31
+ file.relative_to(base_path)
32
+ except ValueError:
33
+ continue
34
+
35
+ rel_path = file.relative_to(base_path)
36
+ current = tree
37
+
38
+ for part in rel_path.parts[:-1]:
39
+ if part not in current:
40
+ current[part] = {}
41
+ current = current[part]
42
+ current[rel_path.parts[-1]] = None
43
+
44
+ except Exception as e:
45
+ print(f"Error processing file {file}: {e}")
46
+ continue
47
+
48
+ except Exception as e:
49
+ print(f"Error generating file structure: {e}")
50
+ return {}
51
+
52
+ return tree
53
+
54
+ def get_files_content(self, exclude_patterns: List[str] = None) -> str:
55
+ """Get content of files in workspace directory in XML format"""
56
+ content = ['<workspaceFiles>']
57
+ exclude_patterns = exclude_patterns or self.default_exclude
58
+ base_path = self.base_path.resolve()
59
+
60
+ for pattern in self.default_patterns:
61
+ for file in sorted(base_path.rglob(pattern)):
62
+ if any(pat in str(file) for pat in exclude_patterns):
63
+ continue
64
+
65
+ try:
66
+ rel_path = file.relative_to(base_path)
67
+ content.append(f' <file path="{rel_path}">')
68
+ content.append(' <content>')
69
+ content.append(file.read_text()) # Remove .strip() to preserve original content
70
+ content.append(' </content>')
71
+ content.append(' </file>')
72
+ except ValueError:
73
+ continue
74
+
75
+ content.append('</workspaceFiles>')
76
+ return "\n".join(content)
77
+
78
+ def format_tree(self, tree: Dict, prefix: str = "", is_last: bool = True) -> List[str]:
79
+ """Format a tree dictionary into a list of strings showing the structure."""
80
+ lines = []
81
+
82
+ if not tree:
83
+ return lines
84
+
85
+ for i, (name, subtree) in enumerate(tree.items()):
86
+ is_last_item = i == len(tree) - 1
87
+ connector = "└── " if is_last_item else "├── "
88
+
89
+ if subtree is None: # File
90
+ lines.append(f"{prefix}{connector}{name}")
91
+ else: # Directory
92
+ lines.append(f"{prefix}{connector}{name}/")
93
+ next_prefix = prefix + (" " if is_last_item else "│ ")
94
+ lines.extend(self.format_tree(subtree, next_prefix))
95
+
96
+ return lines
97
+
98
+ def get_workspace_status(self) -> str:
99
+ """Get a formatted string of the workspace structure"""
100
+ tree = self.generate_file_structure()
101
+ if not tree:
102
+ return "No files found in the current workspace."
103
+ tree_lines = self.format_tree(tree)
104
+ return "Files in workspace:\n" + "\n".join(tree_lines)
105
+
106
+ def get_excluded_files(self) -> List[str]:
107
+ """Get a list of files and directories that are excluded from the workspace"""
108
+ exclude_patterns = self.default_exclude
109
+ gitignore_paths = list(self.base_path.rglob(".gitignore"))
110
+ for p in gitignore_paths:
111
+ with open(p) as f:
112
+ ignore_patterns = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
113
+ exclude_patterns.extend(ignore_patterns)
114
+
115
+ excluded_files = []
116
+ base_path = self.base_path.resolve()
117
+
118
+ for file in base_path.rglob('*'):
119
+ if any(fnmatch.fnmatch(str(file), pat) for pat in exclude_patterns):
120
+ try:
121
+ rel_path = file.relative_to(base_path)
122
+ excluded_files.append(str(rel_path))
123
+ except ValueError:
124
+ continue
125
+
126
+ return excluded_files
127
+
128
+ def print_excluded_files(self):
129
+ """Print files and patterns excluded from workspace"""
130
+ excluded_files = self.get_excluded_files()
131
+ if not excluded_files:
132
+ self.console.print("No excluded files or directories found.")
133
+ return
134
+
135
+ self.console.print("\nExcluded files and directories:")
136
+ for path in excluded_files:
137
+ self.console.print(f" {path}")
138
+
139
+ def calculate_stats(self) -> Dict[str, int]:
140
+ """Calculate statistics about the current directory contents."""
141
+ stats = {
142
+ "total_files": 0,
143
+ "total_dirs": 0,
144
+ "total_size": 0
145
+ }
146
+ for path in self.base_path.rglob('*'):
147
+ if path.is_file():
148
+ stats["total_files"] += 1
149
+ stats["total_size"] += path.stat().st_size
150
+ elif path.is_dir():
151
+ stats["total_dirs"] += 1
152
+ return stats
153
+
154
+
155
+ def print_workspace_structure(self):
156
+ """Print the workspace structure with statistics"""
157
+ stats = self.calculate_stats()
158
+ tree = self.generate_file_structure()
159
+ if not tree:
160
+ print("No files found in the current workspace.")
161
+ return
162
+ tree_lines = self.format_tree(tree)
163
+ print("Workspace structure:")
164
+ print("=" * 80)
165
+ print(f"Total files: {stats['total_files']}")
166
+ print(f"Total directories: {stats['total_dirs']}")
167
+ print(f"Total size: {stats['total_size']} bytes")
168
+ print("=" * 80)
169
+ print("\n".join(tree_lines))
@@ -0,0 +1,202 @@
1
+ from typing import List
2
+ from pathlib import Path
3
+ import re
4
+ from dataclasses import dataclass
5
+ from rich.console import Console
6
+
7
+ @dataclass
8
+ class XMLBlock:
9
+ """Simple container for parsed XML blocks"""
10
+ description: str = ""
11
+ old_content: List[str] = None
12
+ new_content: List[str] = None
13
+
14
+ def __post_init__(self):
15
+ if self.old_content is None:
16
+ self.old_content = []
17
+ if self.new_content is None:
18
+ self.new_content = []
19
+
20
+ @dataclass
21
+ class XMLChange:
22
+ """Simple container for parsed XML changes"""
23
+ path: Path
24
+ operation: str
25
+ blocks: List[XMLBlock] = None
26
+ content: str = ""
27
+
28
+ def __post_init__(self):
29
+ if self.blocks is None:
30
+ self.blocks = []
31
+
32
+ class XMLChangeParser:
33
+ """XML parser for file changes"""
34
+ def __init__(self):
35
+ self.console = Console()
36
+ self.current_operation = None
37
+ self.has_invalid_tags = False # Track invalid tag occurrences
38
+
39
+ def _validate_tag_format(self, line: str) -> bool:
40
+ """Validate that a line contains only a single XML tag and nothing else"""
41
+ stripped = line.strip()
42
+ if not stripped:
43
+ return True
44
+ if stripped.startswith('<?xml'):
45
+ return True
46
+
47
+ # Allow empty content tags in one line
48
+ if stripped in ('<oldContent></oldContent>', '<newContent></newContent>'):
49
+ return True
50
+
51
+ # Check if line contains exactly one XML tag and nothing else
52
+ return bool(re.match(r'^\s*<[^>]+>\s*$', line))
53
+
54
+ def _validate_path(self, path_str: str) -> bool:
55
+ """Validate that path is relative and does not try to escape workspace"""
56
+ try:
57
+ path = Path(path_str)
58
+ # Check if path is absolute
59
+ if path.is_absolute():
60
+ self.console.print(f"[red]Error: Path must be relative: {path_str}[/]")
61
+ return False
62
+ # Check for path traversal attempts
63
+ if '..' in path.parts:
64
+ self.console.print(f"[red]Error: Path cannot contain '..': {path_str}[/]")
65
+ return False
66
+ return True
67
+ except Exception:
68
+ self.console.print(f"[red]Error: Invalid path format: {path_str}[/]")
69
+ return False
70
+
71
+ def parse_response(self, response: str) -> List[XMLChange]:
72
+ """Parse XML response according to format specification:
73
+ <fileChanges>
74
+ <change path="file.py" operation="create|modify">
75
+ <block description="Description of changes">
76
+ <oldContent>
77
+ // Exact content to be replaced (empty for create/append)
78
+ // Must match existing indentation exactly
79
+ </oldContent>
80
+ <newContent>
81
+ // New content to replace the old content
82
+ // Must include desired indentation
83
+ </newContent>
84
+ </block>
85
+ </change>
86
+ </fileChanges>
87
+ """
88
+ changes = []
89
+ current_change = None
90
+ current_block = None
91
+ current_section = None
92
+ content_buffer = []
93
+ in_content = False
94
+ self.current_operation = None
95
+ self.has_invalid_tags = False # Reset at start of parsing
96
+
97
+ try:
98
+ lines = response.splitlines()
99
+ for i, line in enumerate(lines):
100
+ stripped = line.strip()
101
+
102
+ # Update operation tracking when encountering a change tag
103
+ if match := re.match(r'<change\s+path="([^"]+)"\s+operation="([^"]+)">', stripped):
104
+ _, operation = match.groups()
105
+ self.current_operation = operation
106
+ self.has_invalid_tags = False # Reset for new change
107
+
108
+ # Reset operation on change end
109
+ elif stripped == '</change>':
110
+ if current_change and not self.has_invalid_tags:
111
+ changes.append(current_change)
112
+ current_change = None
113
+ self.current_operation = None
114
+ continue
115
+
116
+ # Validate tag format
117
+ if not self._validate_tag_format(line) and not in_content:
118
+ self.console.print(f"[red]Invalid tag format at line {i+1}: {line}[/]")
119
+ self.has_invalid_tags = True
120
+ continue
121
+
122
+ if not stripped and not in_content:
123
+ continue
124
+
125
+ if stripped.startswith('<fileChanges>'):
126
+ continue
127
+ elif stripped.startswith('</fileChanges>'):
128
+ break
129
+
130
+ elif match := re.match(r'<change\s+path="([^"]+)"\s+operation="([^"]+)">', stripped):
131
+ path, operation = match.groups()
132
+ # Validate path before creating change object
133
+ if not self._validate_path(path):
134
+ self.has_invalid_tags = True
135
+ continue
136
+ if operation not in ('create', 'modify'):
137
+ self.console.print(f"[red]Invalid operation '{operation}' - skipping change[/]")
138
+ continue
139
+ current_change = XMLChange(Path(path), operation)
140
+ current_block = None
141
+ elif stripped == '</change>':
142
+ if current_change:
143
+ changes.append(current_change)
144
+ current_change = None
145
+ continue
146
+
147
+ elif match := re.match(r'<block\s+description="([^"]+)">', stripped):
148
+ if current_change:
149
+ current_block = XMLBlock(description=match.group(1))
150
+ elif stripped == '</block>':
151
+ if current_change and current_block:
152
+ current_change.blocks.append(current_block)
153
+ current_block = None
154
+ continue
155
+
156
+ elif stripped in ('<oldContent>', '<newContent>'):
157
+ if current_block:
158
+ current_section = 'old' if 'old' in stripped else 'new'
159
+ content_buffer = []
160
+ in_content = True
161
+ elif stripped in ('</oldContent>', '</newContent>'):
162
+ if current_block and in_content:
163
+ # Find the common indentation of non-empty lines
164
+ non_empty_lines = [line for line in content_buffer if line.strip()]
165
+ if non_empty_lines:
166
+ # Find minimal indent by looking at first real line
167
+ first_line = next(line for line in content_buffer if line.strip())
168
+ indent = len(first_line) - len(first_line.lstrip())
169
+ # Remove only the common indentation from XML
170
+ content = []
171
+ for line in content_buffer:
172
+ if line.strip():
173
+ # Remove only the XML indentation
174
+ content.append(line[indent:])
175
+ elif content: # Keep empty lines only if we have previous content
176
+ content.append('')
177
+ else:
178
+ content = []
179
+
180
+ if current_section == 'old':
181
+ current_block.old_content = content
182
+ else:
183
+ current_block.new_content = content
184
+ in_content = False
185
+ current_section = None
186
+ continue
187
+
188
+ elif in_content:
189
+ # Store lines with their original indentation
190
+ content_buffer.append(line)
191
+ elif current_change and not current_block and not stripped.startswith('<'):
192
+ if stripped:
193
+ current_change.content += line + '\n'
194
+
195
+ return [c for c in changes if not self.has_invalid_tags]
196
+
197
+ except Exception as e:
198
+ self.console.print(f"[red]Error parsing XML: {str(e)}[/]")
199
+ self.console.print(f"[red]Error occurred at line {i + 1}:[/]")
200
+ self.console.print("\nOriginal response:")
201
+ self.console.print(response)
202
+ return []
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [year] [fullname]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.1
2
+ Name: janito
3
+ Version: 0.1.0
4
+ Summary: Language-Driven Software Development Assistant powered by Claude AI
5
+ Home-page: https://github.com/joaompinto/janito
6
+ Author: João M. Pinto
7
+ Author-email: "João M. Pinto" <lamego.pinto@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/joaompinto/janito
10
+ Project-URL: Documentation, https://github.com/joaompinto/janito#readme
11
+ Project-URL: Repository, https://github.com/joaompinto/janito.git
12
+ Keywords: ai,development,claude,assistant,code
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: anthropic
17
+ Requires-Dist: prompt_toolkit
18
+ Requires-Dist: rich
19
+ Requires-Dist: typer
20
+ Requires-Dist: watchdog
21
+ Requires-Dist: pytest
22
+
23
+
24
+ Janito is an open source Language-Driven Software Development Assistant powered by Claude AI. It helps developers understand, modify, and improve their Python code through natural language interaction.
25
+
26
+ ## Features
27
+
28
+ - Natural language code interactions
29
+ - File system monitoring and auto-restart
30
+ - Interactive command-line interface
31
+ - Workspace management and visualization
32
+ - History and session management
33
+ - Debug mode for troubleshooting
34
+ - Syntax error detection and fixing
35
+ - Python file execution
36
+ - File editing with system editor
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ # Install package
42
+ pip install janito
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Start Janito in your project directory:
48
+
49
+ ```bash
50
+ python -m janito
51
+ ```
52
+
53
+ Or launch with options:
54
+
55
+ ```bash
56
+ python -m janito --debug # Enable debug mode
57
+ python -m janito --no-watch # Disable file watching
58
+ ```
59
+
60
+ ### Commands
61
+
62
+ - `.help` - Show help information
63
+ - `.exit` - Exit the console
64
+ - `.clear` - Clear console output
65
+ - `.debug` - Toggle debug mode
66
+ - `.workspace` - Show workspace structure
67
+ - `.last` - Show last Claude response
68
+ - `.show <file>` - Show file content with syntax highlighting
69
+ - `.check` - Check workspace Python files for syntax errors
70
+ - `.p <file>` - Run a Python file
71
+ - `.python <file>` - Run a Python file (alias for .p)
72
+ - `.edit <file>` - Open file in system editor
73
+
74
+ ### Input Formats
75
+
76
+ - `!request` - Request file changes (e.g. '!add logging to utils.py')
77
+ - `request?` - Get information and analysis without changes
78
+ - `request` - General discussion and queries
79
+ - `$command` - Execute shell commands
80
+
81
+ ## Configuration
82
+
83
+ Requires an Anthropic API key set via environment variable:
84
+
85
+ ```bash
86
+ export ANTHROPIC_API_KEY='your_api_key_here'
87
+ ```
88
+
89
+ ## Development
90
+
91
+ The package consists of several modules:
92
+
93
+ - `janito.py` - Core functionality and CLI interface
94
+ - `change.py` - File modification and change tracking
95
+ - `claude.py` - Claude API interaction
96
+ - `console.py` - Interactive console and command handling
97
+ - `commands.py` - Command implementations
98
+ - `prompts.py` - Prompt templates and builders
99
+ - `watcher.py` - File system monitoring
100
+ - `workspace.py` - Workspace analysis and management
101
+ - `xmlchangeparser.py` - XML parser for file changes
102
+ - `watcher.py` - File system monitoring
103
+
104
+ ## License
105
+
106
+ MIT License
@@ -0,0 +1,19 @@
1
+ janito/__init__.py,sha256=y6hOI0whYLZTk73Uoq-dxcCGKdPmAH9Fa5ywXV4CIaM,87
2
+ janito/__main__.py,sha256=e_8IHo0JtPt8lgS43UTUzkaIu47SZ8PjhOomNqrt6Q4,173
3
+ janito/change.py,sha256=4GueUiqKx6fqzmwaxTyj9n-rifyc54IUdoxmJHxnnBc,17898
4
+ janito/claude.py,sha256=KFyhAqRKY0iUMiuazlpvPlR6uufJCkkxZx_Jxeqdu1w,4294
5
+ janito/commands.py,sha256=CqP0oOA9Rl6siQT0mNo3jeB7ubZwSCxsmXLTA0ostAM,15305
6
+ janito/console.py,sha256=-SqCPFBt-7gqROCWlkGUcmOAJGC1_YnS5cuwXZITHAk,14909
7
+ janito/janito.py,sha256=bgddRXQNQj28scRYNhXPeA3T3Jh84A9EOZApZ_OD0us,13311
8
+ janito/prompts.py,sha256=4TkFnam7Ya8sBUQ9k5iAt72Ca7-k9KPmKjaqilfCX6U,5879
9
+ janito/watcher.py,sha256=8icD9XnHnYpy_XI_i5Dg6tHpw27ecJie2ZqpEo66COY,3363
10
+ janito/workspace.py,sha256=do66QRQ2VIpMofJuyVUyS_Gp5WMDG-5XoNYm270Zv0s,6979
11
+ janito/xmlchangeparser.py,sha256=kfMa9AntsPBhG_R2NHFU3DZsaRh5BXRREZCnCfR1SDs,8576
12
+ tests/__init__.py,sha256=EjWgRAmKaumanB8t00SWGaTu2b7AuZNDiMxqYoAhVJI,26
13
+ tests/conftest.py,sha256=_8lI6aTT2s1A14ggojPW-bI0imOj1JmXwdR1_2BPuCw,185
14
+ tests/test_change.py,sha256=wqufymSOo4ypMorMpQk9oyJYeYgO6Zn1H2GhU1Wx2ls,12482
15
+ janito-0.1.0.dist-info/LICENSE,sha256=pAZXnNE2dxxwXFIduGyn1gpvPefJtUYOYZOi3yeGG94,1068
16
+ janito-0.1.0.dist-info/METADATA,sha256=ROEPyzYWR3q8ibD4bpLGQGTyQBGEkpRx0IyKxVFHfc0,2979
17
+ janito-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
18
+ janito-0.1.0.dist-info/top_level.txt,sha256=b9vGz4twQlT5Sx2aD5wtd9NbXg3N40FjXgGRpcbKGVQ,13
19
+ janito-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ janito
2
+ tests
tests/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+
2
+ """
3
+ Janito test suite
4
+ """
tests/conftest.py ADDED
@@ -0,0 +1,9 @@
1
+
2
+ import pytest
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ # Add the project root to the Python path
8
+ project_root = Path(__file__).parent.parent
9
+ sys.path.insert(0, str(project_root))