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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+
2
+ """
3
+ Janito - Language-Driven Software Development Assistant
4
+ """
5
+
6
+ __version__ = "0.1.0"
janito/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Entry point for Janito package.
3
+ Allows running the application using 'python -m janito'.
4
+ """
5
+
6
+ from janito.janito import run_cli
7
+
8
+ if __name__ == '__main__':
9
+ run_cli()
janito/change.py ADDED
@@ -0,0 +1,382 @@
1
+ from typing import Optional, List, Set
2
+ import re
3
+ from pathlib import Path
4
+ import ast
5
+ import shutil
6
+ from rich.syntax import Syntax
7
+ from rich.console import Console
8
+ from rich.markdown import Markdown
9
+ import tempfile
10
+ import os # Add this import
11
+ from janito.workspace import Workspace
12
+ from janito.xmlchangeparser import XMLChangeParser, XMLChange
13
+
14
+ class FileChangeHandler:
15
+ def __init__(self, interactive=True):
16
+ self.preview_dir = Path(tempfile.mkdtemp(prefix='janito_preview_'))
17
+ self.console = Console()
18
+ self.workspace = Workspace()
19
+ self.xml_parser = XMLChangeParser()
20
+ self.interactive = interactive
21
+
22
+ # Remove generate_changes_prompt method as it's not being used
23
+
24
+ # Remove _parse_xml_response method as it's replaced by xml_parser
25
+
26
+ def test_parse_empty_block(self) -> bool:
27
+ """Test parsing of XML with empty content blocks"""
28
+ test_xml = '''<fileChanges>
29
+ <change path="hello.py" operation="create">
30
+ <block description="Create new file hello.py">
31
+ <oldContent></oldContent>
32
+ <newContent></newContent>
33
+ </block>
34
+ </change>
35
+ </fileChanges>'''
36
+
37
+ changes = self.xml_parser.parse_response(test_xml)
38
+ if not changes:
39
+ self.console.print("[red]Error: No changes parsed[/]")
40
+ return False
41
+
42
+ change = changes[0]
43
+ if (change.path.name != "hello.py" or
44
+ change.operation != "create" or
45
+ not change.blocks or
46
+ change.blocks[0].description != "Create new file hello.py"):
47
+ self.console.print("[red]Error: Parsed change does not match expected structure[/]")
48
+ return False
49
+
50
+ block = change.blocks[0]
51
+ if block.old_content != [] or block.new_content != []:
52
+ self.console.print("[red]Error: Content lists should be empty[/]")
53
+ return False
54
+
55
+ self.console.print("[green]Empty block parsing test passed[/]")
56
+ return True
57
+
58
+ def _validate_syntax(self, filepath: Path) -> tuple[Optional[SyntaxError], bool]:
59
+ """Validate file syntax
60
+ Returns (error, supported):
61
+ - (None, True) -> valid syntax
62
+ - (SyntaxError, True) -> invalid syntax
63
+ - (None, False) -> unsupported file type
64
+ """
65
+ # Add more file types as needed
66
+ SUPPORTED_TYPES = {
67
+ '.py': self._validate_python_syntax,
68
+ }
69
+
70
+ validator = SUPPORTED_TYPES.get(filepath.suffix)
71
+ if not validator:
72
+ return None, False
73
+
74
+ try:
75
+ error = validator(filepath)
76
+ return error, True
77
+ except Exception as e:
78
+ return SyntaxError(str(e)), True
79
+
80
+ def _validate_python_syntax(self, filepath: Path) -> Optional[SyntaxError]:
81
+ """Validate Python syntax"""
82
+ try:
83
+ with open(filepath) as f:
84
+ ast.parse(f.read())
85
+ return None
86
+ except SyntaxError as e:
87
+ return e
88
+
89
+ def _apply_indentation(self, new_content: List[str], base_indent: int, first_line_indent: Optional[int] = None) -> List[str]:
90
+ """Apply consistent indentation to new content
91
+ Args:
92
+ new_content: List of lines to indent
93
+ base_indent: Base indentation level to apply
94
+ first_line_indent: Optional indentation of first line in original block for relative indenting
95
+ Returns:
96
+ List of indented lines
97
+ """
98
+ if not new_content:
99
+ return []
100
+
101
+ indented_content = []
102
+ for i, line in enumerate(new_content):
103
+ if not line.strip():
104
+ indented_content.append('')
105
+ continue
106
+
107
+ # For first non-empty line, use base indentation
108
+ if not indented_content or all(not l.strip() for l in indented_content):
109
+ curr_indent = base_indent
110
+ else:
111
+ # Calculate relative indentation from first line
112
+ if first_line_indent is None:
113
+ first_line_indent = len(new_content[0]) - len(new_content[0].lstrip())
114
+ # Maintain relative indentation from first line
115
+ curr_indent = base_indent + (len(line) - len(line.lstrip()) - first_line_indent)
116
+ indented_content.append(' ' * max(0, curr_indent) + line.lstrip())
117
+
118
+ return indented_content
119
+
120
+ def _create_preview_files(self, changes: List[XMLChange]) -> dict[Path, Path]:
121
+ """Create preview files for all changes"""
122
+ preview_files = {}
123
+
124
+ for change in changes:
125
+ preview_path = self.preview_dir / change.path.name
126
+
127
+ if change.operation == 'create':
128
+ # For new files, use direct content or block content
129
+ content = change.content
130
+ if not content.strip() and change.blocks:
131
+ content = "\n".join(change.blocks[0].new_content)
132
+ preview_path.write_text(content)
133
+
134
+ elif change.operation == 'modify' and change.path.exists():
135
+ original_text = change.path.read_text()
136
+ original_content = original_text.splitlines()
137
+ modified_content = original_content.copy()
138
+
139
+ for block in change.blocks:
140
+ if not block.old_content or (len(block.old_content) == 1 and not block.old_content[0].strip()):
141
+ if block.new_content:
142
+ if modified_content and not original_text.endswith('\n'):
143
+ modified_content[-1] = modified_content[-1] + '\n'
144
+ # Get the last line's indentation for appends
145
+ base_indent = len(modified_content[-1]) - len(modified_content[-1].lstrip()) if modified_content else 0
146
+ indented_content = self._apply_indentation(block.new_content, base_indent)
147
+ modified_content.extend(indented_content)
148
+ else:
149
+ result = self._find_block_start(modified_content, block.old_content, preview_path)
150
+ if result is None:
151
+ continue
152
+ start_idx, base_indent = result
153
+
154
+ # For single-line deletions/replacements
155
+ if len(block.old_content) == 1:
156
+ if block.new_content:
157
+ # Replace single line
158
+ indented_content = self._apply_indentation(block.new_content, base_indent)
159
+ modified_content[start_idx:start_idx + 1] = indented_content
160
+ else:
161
+ # Delete single line
162
+ del modified_content[start_idx]
163
+ else:
164
+ # Multi-line block handling
165
+ end_idx = start_idx + len([l for l in block.old_content if l.strip()])
166
+ if block.new_content:
167
+ indented_content = self._apply_indentation(block.new_content, base_indent)
168
+ modified_content[start_idx:end_idx] = indented_content
169
+ else:
170
+ del modified_content[start_idx:end_idx]
171
+
172
+ preview_path.write_text('\n'.join(modified_content))
173
+
174
+ preview_files[change.path] = preview_path
175
+
176
+ return preview_files
177
+
178
+ def _preview_changes(self, changes: List[XMLChange], raw_response: str = None) -> tuple[bool, bool]:
179
+ """Show preview of all changes and ask for confirmation
180
+ Returns: (success, has_syntax_errors)"""
181
+ # Create preview files
182
+ preview_files = self._create_preview_files(changes)
183
+
184
+ # Validate syntax for all preview files
185
+ validation_status = {}
186
+ has_syntax_errors = False
187
+ for orig_path, preview_path in preview_files.items():
188
+ error, supported = self._validate_syntax(preview_path)
189
+ if not supported:
190
+ validation_status[orig_path] = "[yellow]? Syntax check not supported[/]"
191
+ elif error:
192
+ validation_status[orig_path] = f"[red]✗ {str(error)}[/]"
193
+ has_syntax_errors = True
194
+ else:
195
+ validation_status[orig_path] = "[green]✓ Valid syntax[/]"
196
+
197
+ self.console.print("\n[cyan]Preview of changes to be applied:[/]")
198
+ self.console.print("=" * 80)
199
+
200
+ for change in changes:
201
+ if change.operation == 'create':
202
+ preview_content = preview_files[change.path].read_text()
203
+ status = validation_status.get(change.path, '')
204
+ self.console.print(f"\n[green]CREATE NEW FILE: {change.path}[/] {status}")
205
+ syntax = Syntax(preview_content, "python", theme="monokai")
206
+ self.console.print(syntax)
207
+ continue
208
+
209
+ if not change.path.exists():
210
+ self.console.print(f"\n[red]SKIP: File not found - {change.path}[/]")
211
+ continue
212
+
213
+ status = validation_status.get(change.path, '')
214
+ self.console.print(f"\n[yellow]MODIFY FILE: {change.path}[/] {status}")
215
+ for block in change.blocks:
216
+ self.console.print(f"\n[cyan]{block.description}[/]")
217
+
218
+ if not block.old_content or (len(block.old_content) == 1 and not block.old_content[0].strip()):
219
+ if block.new_content:
220
+ # Get the last line's indentation
221
+ file_lines = change.path.read_text().splitlines() if change.path.exists() else []
222
+ base_indent = len(file_lines[-1]) - len(file_lines[-1].lstrip()) if file_lines else 0
223
+ indented_content = self._apply_indentation(block.new_content, base_indent)
224
+ self.console.print("[green]Append to end of file:[/]")
225
+ syntax = Syntax("\n".join(indented_content), "python", theme="monokai")
226
+ self.console.print(syntax)
227
+ else:
228
+ self.console.print("[black on red]Remove:[/]")
229
+ syntax = Syntax("\n".join(block.old_content), "python", theme="monokai")
230
+ self.console.print(syntax)
231
+ if block.new_content: # Only show replacement if there is new content
232
+ self.console.print("\n[black on green]Replace with:[/]")
233
+ syntax = Syntax("\n".join(block.new_content), "python", theme="monokai")
234
+ self.console.print(syntax)
235
+ else:
236
+ self.console.print("[black on yellow](Content will be deleted)[/]")
237
+
238
+ self.console.print("\n" + "=" * 80)
239
+
240
+ if has_syntax_errors:
241
+ self.console.print("\n[red]⚠️ Error: Cannot apply changes - syntax errors detected![/]")
242
+ return False, has_syntax_errors
243
+
244
+ # Only ask for confirmation if interactive and no syntax errors
245
+ if self.interactive:
246
+ try:
247
+ response = input("\nApply these changes? [y/N] ").lower().strip()
248
+ return response == 'y', has_syntax_errors
249
+ except EOFError:
250
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-D)[/]")
251
+ return False, has_syntax_errors
252
+ return True, has_syntax_errors
253
+
254
+ def process_changes(self, response: str) -> bool:
255
+ try:
256
+ if not (match := re.search(r'<fileChanges>(.*?)</fileChanges>', response, re.DOTALL)):
257
+ self.console.print("[red]No file changes found in response[/]")
258
+ self.console.print("\nResponse content:")
259
+ self.console.print(response)
260
+ return False
261
+
262
+ xml_content = f"<fileChanges>{match.group(1)}</fileChanges>"
263
+ self.console.print("[cyan]Found change block, parsing...[/]")
264
+
265
+ changes = self.xml_parser.parse_response(xml_content)
266
+ if not changes:
267
+ self.console.print("[red]No valid changes found after parsing[/]")
268
+ return False
269
+
270
+ try:
271
+ # First phase: Create and validate all preview files
272
+ preview_result, has_syntax_errors = self._preview_changes(changes, raw_response=response)
273
+ if not preview_result:
274
+ if self.interactive and not has_syntax_errors:
275
+ self.console.print("[yellow]Changes cancelled by user[/]")
276
+ return False
277
+
278
+ # Second phase: Pre-validate all files can be written to
279
+ preview_files = self._create_preview_files(changes)
280
+ for change in changes:
281
+ preview_path = preview_files.get(change.path)
282
+ if not preview_path or not preview_path.exists():
283
+ self.console.print(f"[red]Preview file missing for {change.path}[/]")
284
+ return False
285
+
286
+ try:
287
+ # Validate write permissions and parent directory creation
288
+ change.path.parent.mkdir(parents=True, exist_ok=True)
289
+ # Test write permission without actually writing
290
+ if change.path.exists():
291
+ os.access(change.path, os.W_OK)
292
+ else:
293
+ change.path.parent.joinpath('test').touch()
294
+ change.path.parent.joinpath('test').unlink()
295
+ except (OSError, IOError) as e:
296
+ self.console.print(f"[red]Cannot write to {change.path}: {e}[/]")
297
+ return False
298
+
299
+ # Final phase: Apply all changes in a batch
300
+ self.console.print("\n[cyan]Applying changes...[/]")
301
+ try:
302
+ # Copy all files in a single transaction-like batch
303
+ for change in changes:
304
+ preview_path = preview_files[change.path]
305
+ shutil.copy2(preview_path, change.path)
306
+ self.console.print(f"[green]{'Created' if change.operation == 'create' else 'Updated'} file: {change.path}[/]")
307
+
308
+ self.console.print("\n[green]✓ All changes applied successfully[/]")
309
+ return True
310
+
311
+ except (OSError, IOError) as e:
312
+ self.console.print(f"[red]Error applying changes: {e}[/]")
313
+ return False
314
+
315
+ except KeyboardInterrupt:
316
+ self.console.print("[yellow]Changes cancelled by user (Ctrl-C)[/]")
317
+ return False
318
+
319
+ except EOFError:
320
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-D)[/]")
321
+ return False
322
+ except KeyboardInterrupt:
323
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-C)[/]")
324
+ return False
325
+ except Exception as e:
326
+ self.console.print(f"\n[red]Error applying changes: {e}[/]")
327
+ return False
328
+
329
+ def _find_block_start(self, content: List[str], old_content: List[str], filepath: Path = None) -> Optional[tuple[int, int]]:
330
+ """Find the start of the indentation block containing old_content"""
331
+ try:
332
+ if not old_content:
333
+ return None
334
+
335
+ # Convert string content to lines if needed
336
+ lines = content if isinstance(content, list) else content.split('\n')
337
+
338
+ # For single-line content, do exact string matching
339
+ if len(old_content) == 1:
340
+ for i, line in enumerate(lines):
341
+ if line.strip() == old_content[0].strip():
342
+ return (i, len(line) - len(line.lstrip()))
343
+ self.console.print(f"[yellow]Warning: Line not found in {filepath.name if filepath else 'unknown file'}: {old_content[0]}[/]")
344
+ return None
345
+
346
+ # For multi-line blocks, use existing block matching logic
347
+ first_line = next((line for line in old_content if line.strip()), '')
348
+ target_indent = len(first_line) - len(first_line.lstrip())
349
+
350
+ # Search for the block
351
+ for i in range(len(lines) - len(old_content) + 1):
352
+ # Check if block matches at this position
353
+ matches = True
354
+ for j, old_line in enumerate(old_content):
355
+ if not old_line.strip(): # Skip empty lines
356
+ continue
357
+ if i + j >= len(lines):
358
+ matches = False
359
+ break
360
+ if lines[i + j].lstrip() != old_line.lstrip():
361
+ matches = False
362
+ break
363
+ if matches:
364
+ return (i, target_indent)
365
+
366
+ self.console.print(f"[yellow]Warning: Block not found in {filepath.name if filepath else 'unknown file'}[/]")
367
+ return None
368
+
369
+ except Exception as e:
370
+ self.console.print(f"[yellow]Error finding block in {filepath.name if filepath else 'unknown file'}: {e}[/]")
371
+ return None
372
+
373
+ def cleanup(self):
374
+ """Clean up preview directory"""
375
+ try:
376
+ shutil.rmtree(self.preview_dir)
377
+ except (OSError, IOError) as e:
378
+ self.console.print(f"[yellow]Warning: Failed to clean up preview directory: {e}[/]")
379
+
380
+ def __del__(self):
381
+ """Ensure cleanup on destruction"""
382
+ self.cleanup()
janito/claude.py ADDED
@@ -0,0 +1,112 @@
1
+ from rich.traceback import install # Add import at top
2
+ import anthropic
3
+ import os
4
+ from pathlib import Path
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from hashlib import sha256
8
+ import re
9
+ import threading
10
+ from typing import List, Optional
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+ from threading import Event
13
+ import time
14
+ from janito.prompts import SYSTEM_PROMPT, build_info_prompt, build_change_prompt, build_general_prompt # Update imports
15
+
16
+ # Install rich traceback handler
17
+ install(show_locals=True)
18
+
19
+ class ClaudeAPIAgent:
20
+ """Handles interaction with Claude API, including message handling"""
21
+ def __init__(self, api_key: Optional[str] = None):
22
+ self.api_key = api_key or os.getenv('ANTHROPIC_API_KEY')
23
+ if not self.api_key:
24
+ raise ValueError("ANTHROPIC_API_KEY environment variable is required")
25
+ self.client = anthropic.Client(api_key=self.api_key)
26
+ self.conversation_history = []
27
+ self.debug = False
28
+ self.stop_progress = Event()
29
+ self.system_message = SYSTEM_PROMPT
30
+ self.last_prompt = None
31
+ self.last_full_message = None
32
+ self.last_response = None
33
+ # Remove workspace instance since it's not used
34
+
35
+ # Remove _get_files_content method since it's not used
36
+
37
+ def send_message(self, message: str, system_prompt: str = None, stop_event: Event = None) -> str:
38
+ """Send message to Claude API and return response"""
39
+ try:
40
+ # Store the full message
41
+ self.last_full_message = message
42
+
43
+ try:
44
+ # Check if already cancelled
45
+ if stop_event and stop_event.is_set():
46
+ return ""
47
+
48
+ # Start API request
49
+ response = self.client.messages.create(
50
+ model="claude-3-opus-20240229",
51
+ system=self.system_message,
52
+ max_tokens=4000,
53
+ messages=[
54
+ {"role": "user", "content": message}
55
+ ],
56
+ temperature=0,
57
+ )
58
+
59
+ # Handle response
60
+ response_text = response.content[0].text
61
+
62
+ # Only store and process response if not cancelled
63
+ if not (stop_event and stop_event.is_set()):
64
+ self.last_response = response_text
65
+
66
+ if self.debug:
67
+ print("\n[Debug] Received response:")
68
+ print("=" * 80)
69
+ print(response_text)
70
+ print("=" * 80)
71
+
72
+ # Update conversation history
73
+ self.conversation_history.append({"role": "user", "content": message})
74
+ self.conversation_history.append({"role": "assistant", "content": response_text})
75
+
76
+ # Always return the response, let caller handle cancellation
77
+ return response_text
78
+
79
+ except KeyboardInterrupt:
80
+ if stop_event:
81
+ stop_event.set()
82
+ return ""
83
+
84
+ except Exception as e:
85
+ if stop_event and stop_event.is_set():
86
+ return ""
87
+ return f"Error: {str(e)}"
88
+
89
+ def toggle_debug(self) -> str:
90
+ """Toggle debug mode on/off"""
91
+ self.debug = not self.debug
92
+ return f"Debug mode {'enabled' if self.debug else 'disabled'}"
93
+
94
+ def clear_history(self) -> str:
95
+ self.conversation_history = []
96
+ return "Conversation history cleared"
97
+
98
+ def save_history(self, filename: str) -> str:
99
+ try:
100
+ with open(filename, 'w') as f:
101
+ json.dump(self.conversation_history, f)
102
+ return f"History saved to {filename}"
103
+ except Exception as e:
104
+ return f"Error saving history: {str(e)}"
105
+
106
+ def load_history(self, filename: str) -> str:
107
+ try:
108
+ with open(filename, 'r') as f:
109
+ self.conversation_history = json.load(f)
110
+ return f"History loaded from {filename}"
111
+ except Exception as e:
112
+ return f"Error loading history: {str(e)}"