janito 0.7.0__py3-none-any.whl → 0.8.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.
Files changed (112) hide show
  1. janito/__main__.py +127 -141
  2. janito/agents/__init__.py +22 -22
  3. janito/agents/agent.py +24 -27
  4. janito/agents/claudeai.py +41 -45
  5. janito/agents/deepseekai.py +47 -0
  6. janito/change/applied_blocks.py +34 -0
  7. janito/change/applier.py +167 -0
  8. janito/change/edit_blocks.py +148 -0
  9. janito/change/finder.py +72 -0
  10. janito/change/request.py +144 -0
  11. janito/change/validator.py +87 -269
  12. janito/change/view/content.py +63 -0
  13. janito/change/{viewer → view}/diff.py +44 -43
  14. janito/change/view/panels.py +201 -0
  15. janito/change/view/sections.py +69 -0
  16. janito/change/view/styling.py +140 -0
  17. janito/change/view/summary.py +37 -0
  18. janito/change/{viewer → view}/themes.py +62 -55
  19. janito/change/view/viewer.py +59 -0
  20. janito/cli/__init__.py +1 -1
  21. janito/cli/commands.py +68 -88
  22. janito/cli/functions.py +66 -111
  23. janito/common.py +132 -79
  24. janito/config.py +99 -101
  25. janito/data/change_prompt.txt +81 -0
  26. janito/data/system_prompt.txt +3 -0
  27. janito/qa.py +56 -57
  28. janito/version.py +22 -22
  29. janito/workspace/__init__.py +8 -6
  30. janito/workspace/analysis.py +120 -120
  31. janito/workspace/{types.py → models.py} +97 -98
  32. janito/workspace/show.py +115 -141
  33. janito/workspace/stats.py +42 -43
  34. janito/workspace/workset.py +135 -108
  35. janito/workspace/workspace.py +335 -114
  36. janito-0.8.0.dist-info/METADATA +106 -0
  37. janito-0.8.0.dist-info/RECORD +40 -0
  38. {janito-0.7.0.dist-info → janito-0.8.0.dist-info}/licenses/LICENSE +20 -20
  39. janito/__init__.py +0 -2
  40. janito/agents/openai.py +0 -57
  41. janito/agents/test.py +0 -34
  42. janito/change/__init__.py +0 -32
  43. janito/change/__main__.py +0 -0
  44. janito/change/analysis/__init__.py +0 -23
  45. janito/change/analysis/__main__.py +0 -7
  46. janito/change/analysis/analyze.py +0 -62
  47. janito/change/analysis/formatting.py +0 -78
  48. janito/change/analysis/options.py +0 -81
  49. janito/change/analysis/prompts.py +0 -90
  50. janito/change/analysis/view/__init__.py +0 -9
  51. janito/change/analysis/view/terminal.py +0 -181
  52. janito/change/applier/__init__.py +0 -5
  53. janito/change/applier/file.py +0 -58
  54. janito/change/applier/main.py +0 -156
  55. janito/change/applier/text.py +0 -247
  56. janito/change/applier/workspace_dir.py +0 -58
  57. janito/change/core.py +0 -124
  58. janito/change/history.py +0 -44
  59. janito/change/operations.py +0 -7
  60. janito/change/parser.py +0 -287
  61. janito/change/play.py +0 -54
  62. janito/change/preview.py +0 -82
  63. janito/change/prompts.py +0 -121
  64. janito/change/test.py +0 -0
  65. janito/change/viewer/__init__.py +0 -11
  66. janito/change/viewer/content.py +0 -66
  67. janito/change/viewer/panels.py +0 -533
  68. janito/change/viewer/styling.py +0 -114
  69. janito/clear_statement_parser/clear_statement_format.txt +0 -328
  70. janito/clear_statement_parser/examples.txt +0 -326
  71. janito/clear_statement_parser/models.py +0 -104
  72. janito/clear_statement_parser/parser.py +0 -496
  73. janito/cli/base.py +0 -30
  74. janito/cli/history.py +0 -61
  75. janito/cli/registry.py +0 -26
  76. janito/demo/__init__.py +0 -4
  77. janito/demo/data.py +0 -13
  78. janito/demo/mock_data.py +0 -20
  79. janito/demo/operations.py +0 -45
  80. janito/demo/runner.py +0 -59
  81. janito/demo/scenarios.py +0 -32
  82. janito/prompt.py +0 -36
  83. janito/review.py +0 -13
  84. janito/search_replace/README.md +0 -192
  85. janito/search_replace/__init__.py +0 -7
  86. janito/search_replace/__main__.py +0 -21
  87. janito/search_replace/core.py +0 -120
  88. janito/search_replace/logger.py +0 -35
  89. janito/search_replace/parser.py +0 -52
  90. janito/search_replace/play.py +0 -61
  91. janito/search_replace/replacer.py +0 -36
  92. janito/search_replace/searcher.py +0 -411
  93. janito/search_replace/strategy_result.py +0 -10
  94. janito/shell/__init__.py +0 -38
  95. janito/shell/bus.py +0 -31
  96. janito/shell/commands.py +0 -136
  97. janito/shell/history.py +0 -20
  98. janito/shell/processor.py +0 -32
  99. janito/shell/prompt.py +0 -48
  100. janito/shell/registry.py +0 -60
  101. janito/tui/__init__.py +0 -21
  102. janito/tui/base.py +0 -22
  103. janito/tui/flows/__init__.py +0 -5
  104. janito/tui/flows/changes.py +0 -65
  105. janito/tui/flows/content.py +0 -128
  106. janito/tui/flows/selection.py +0 -117
  107. janito/tui/screens/__init__.py +0 -3
  108. janito/tui/screens/app.py +0 -1
  109. janito-0.7.0.dist-info/METADATA +0 -167
  110. janito-0.7.0.dist-info/RECORD +0 -96
  111. {janito-0.7.0.dist-info → janito-0.8.0.dist-info}/WHEEL +0 -0
  112. {janito-0.7.0.dist-info → janito-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -1,269 +1,87 @@
1
- import ast
2
- from pathlib import Path
3
- from typing import Optional, Tuple, List, Set
4
- from rich.console import Console
5
- from rich.prompt import Confirm
6
- from rich.panel import Panel
7
- from rich.columns import Columns
8
- from rich import box
9
-
10
- from janito.common import progress_send_message
11
- from janito.change.history import save_changes_to_history
12
- from janito.config import config
13
- from janito.workspace import workset # Updated import
14
- from .viewer import preview_all_changes
15
- from .parser import FileChange, ChangeOperation
16
-
17
- from .analysis import analyze_request
18
-
19
- def validate_file_operations(changes: List[FileChange], collected_files: Set[Path]) -> Tuple[bool, str]:
20
- """Validate file operations against current filesystem state.
21
-
22
- Validates:
23
- - Path conflicts:
24
- - Parent directory exists and is a directory
25
- - No Python module name conflicts (dir vs file)
26
- - Text modifications:
27
- - Search content required for non-append modifications
28
- - Replace content required for append operations
29
-
30
- Args:
31
- changes: List of file changes to validate
32
- collected_files: Set of files that were found during scanning
33
-
34
- Returns:
35
- Tuple[bool, str]: (is_valid, error_message)
36
- """
37
- for change in changes:
38
- # For modify operations, validate text changes
39
- if change.operation == ChangeOperation.MODIFY_FILE:
40
- for mod in change.text_changes:
41
- if not mod.is_delete and not mod.is_append:
42
- if not mod.search_content:
43
- return False, f"Search content required for modification in {change.name}"
44
-
45
- if mod.is_append and not mod.replace_content:
46
- return False, f"Replace content required for append operation in {change.name}"
47
-
48
- # Check for directory/file conflicts
49
- if change.operation == ChangeOperation.CREATE_FILE:
50
- parent = change.name.parent
51
- if parent.exists() and not parent.is_dir():
52
- return False, f"Cannot create file - parent path exists as file: {parent}"
53
-
54
- # Check for Python module conflicts
55
- if change.name.suffix == '.py':
56
- module_dir = change.name.with_suffix('')
57
- if module_dir.exists() and module_dir.is_dir():
58
- return False, f"Cannot create Python file - directory with same name exists: {module_dir}"
59
-
60
- # Basic rename validation (without existence checks)
61
- if change.operation == ChangeOperation.RENAME_FILE:
62
- if not change.source or not change.target:
63
- return False, "Rename operation requires both source and target paths"
64
-
65
- return True, ""
66
- from pathlib import Path
67
- from typing import Tuple, List, Set, Optional
68
- from .parser import FileChange, ChangeOperation
69
-
70
- def validate_python_syntax(code: str, filepath: Path | str) -> Tuple[bool, str]:
71
- """Validate Python code syntax using ast parser.
72
-
73
- Args:
74
- code: Python source code to validate
75
- filepath: Path or string of the file (used for error messages)
76
-
77
- Returns:
78
- Tuple of (is_valid, error_message)
79
- - is_valid: True if syntax is valid
80
- - error_message: Empty string if valid, error details if invalid
81
- """
82
- try:
83
- ast.parse(code)
84
- return True, ""
85
- except SyntaxError as e:
86
- # Get detailed error information
87
- line_num = e.lineno if e.lineno is not None else 0
88
- col_num = e.offset if e.offset is not None else 0
89
- line = e.text or ""
90
-
91
- # Build error message with line pointer
92
- pointer = " " * (col_num - 1) + "^" if col_num > 0 else ""
93
- error_msg = (
94
- f"Syntax error at {filepath}:{line_num}:{col_num}\n"
95
- f"{line}\n"
96
- f"{pointer}\n"
97
- f"Error: {str(e)}"
98
- )
99
- return False, error_msg
100
- except Exception as e:
101
- return False, f"Parsing error in {filepath}: {str(e)}"
102
-
103
- def validate_change(change: FileChange) -> Tuple[bool, Optional[str]]:
104
- """Validate a single FileChange object for structural correctness.
105
-
106
- Validates:
107
- - Required fields (name, operation type)
108
- - Operation-specific requirements:
109
- - Create/Replace: content is required
110
- - Rename: target path is required
111
- - Modify: at least one text change required
112
- - Text change validations:
113
- - Delete: search_content is required
114
- - Replace: both search_content and replace_content required
115
- - Prevents duplicate search patterns
116
-
117
- Args:
118
- change: FileChange object to validate
119
-
120
- Returns:
121
- Tuple[bool, Optional[str]]: (is_valid, error_message)
122
- """
123
- if not change.name:
124
- return False, "File name is required"
125
-
126
- operation = change.operation.name.title().lower()
127
- if operation not in ['create_file', 'replace_file', 'remove_file', 'rename_file', 'modify_file', 'move_file']:
128
- return False, f"Invalid file operation: {change.operation}"
129
-
130
- if operation in ['rename_file', 'move_file'] and not change.target:
131
- return False, "Target file path is required for rename/move operation"
132
-
133
- if operation in ['create_file', 'replace_file']:
134
- if not change.content:
135
- return False, f"Content is required for {change.operation} operation"
136
-
137
- if operation == 'modify_file':
138
- if not change.text_changes:
139
- return False, "At least one modification is required for modify operation"
140
-
141
- # Track search texts to avoid duplicates
142
- seen_search_texts = set()
143
- for mod in change.text_changes:
144
- # Validate append operations
145
- if mod.is_append:
146
- if not mod.replace_content:
147
- return False, "Replace content required for append operation"
148
- # Validate other operations
149
- elif not mod.is_delete:
150
- if not mod.search_content:
151
- return False, "Search content required for non-append modification"
152
-
153
- if mod.search_content:
154
- seen_search_texts.add(mod.search_content)
155
-
156
- return True, None
157
-
158
- def validate_all_changes(changes: List[FileChange], collected_files: Set[Path]) -> Tuple[bool, Optional[str]]:
159
- """Validates all aspects of the requested changes.
160
-
161
- Performs complete validation in two phases:
162
- 1. Individual change validation:
163
- - Structure and content requirements
164
- - Operation-specific validations
165
- - Text modification validations
166
- 2. Filesystem state validation:
167
- - File existence checks
168
- - Path conflict checks
169
- - Python module conflict checks
170
-
171
- Args:
172
- changes: List of changes to validate
173
- collected_files: Set of files found during scanning
174
-
175
- Returns:
176
- Tuple[bool, Optional[str]]: (is_valid, error_message)
177
- - If valid, error_message will be None
178
- - If invalid, error_message will describe the validation failure
179
- """
180
- # First validate individual changes
181
- for change in changes:
182
- is_valid, error = validate_change(change)
183
- if not is_valid:
184
- return False, f"Invalid change for {change.name}: {error}"
185
-
186
- # Then validate file operations against filesystem
187
- is_valid, error = validate_file_operations(changes, collected_files)
188
- if not is_valid:
189
- return False, error
190
-
191
- return True, None
192
-
193
- def validate_file_operations(changes: List[FileChange], collected_files: Set[Path]) -> Tuple[bool, str]:
194
- """Validate file operations against current filesystem state.
195
-
196
- Validates:
197
- - File existence for operations that require it:
198
- - Modify: file must exist in collected files
199
- - Replace: file must exist in collected files
200
- - Remove: file must exist in collected files
201
- - File non-existence for operations that require it:
202
- - Create: file must not exist (unless marked as new)
203
- - Rename target: target must not exist
204
- - Path conflicts:
205
- - Parent directory exists and is a directory
206
- - No Python module name conflicts (dir vs file)
207
- - Text modifications:
208
- - Search content required for non-append modifications
209
- - Replace content required for append operations
210
-
211
- Args:
212
- changes: List of file changes to validate
213
- collected_files: Set of files that were found during scanning, includes state metadata
214
-
215
- Returns:
216
- Tuple[bool, str]: (is_valid, error_message)
217
- """
218
- for change in changes:
219
- # For modify operations, validate text changes
220
- if change.operation == ChangeOperation.MODIFY_FILE:
221
- for mod in change.text_changes:
222
- if not mod.is_delete and not mod.is_append:
223
- if not mod.search_content:
224
- return False, f"Search content required for modification in {change.name}"
225
-
226
- if mod.is_append and not mod.replace_content:
227
- return False, f"Replace content required for append operation in {change.name}"
228
-
229
- # Get file state if available
230
- file_state = change.name.name.split(' (')[-1].rstrip(')') if ' (' in str(change.name) else None
231
- is_new_file = file_state == 'new' if file_state else False
232
-
233
- # Validate file exists for operations requiring it
234
- if change.operation in (ChangeOperation.MODIFY_FILE, ChangeOperation.REPLACE_FILE, ChangeOperation.REMOVE_FILE):
235
- if change.name not in collected_files and not is_new_file:
236
- return False, f"File not found in scanned files: {change.name}"
237
-
238
-
239
- # Validate rename/move operations
240
- if change.operation in (ChangeOperation.RENAME_FILE, ChangeOperation.MOVE_FILE):
241
- if not change.source or not change.target:
242
- return False, "Rename/move operation requires both source and target paths"
243
- if change.source not in collected_files and not is_new_file:
244
- return False, f"Source file not found for rename/move: {change.source}"
245
-
246
- return True, ""
247
-
248
- def process_change_request(request: str) -> None:
249
- """Process a change request by analyzing, validating and applying changes."""
250
- # Ensure workset is refreshed before processing changes
251
-
252
- # Analyze the request and get proposed changes
253
- changes = analyze_request(request, workset._workspace.content)
254
- if not changes:
255
- return
256
-
257
- # Collect the set of scanned files from workspace content
258
- collected_files = {Path(line.replace('<path>', '').replace('</path>', '').strip())
259
- for line in workset._workspace.content.split('\n')
260
- if line.startswith('<path>')}
261
-
262
- # Validate changes
263
- is_valid, error = validate_all_changes(changes, collected_files)
264
- if not is_valid:
265
- console = Console()
266
- console.print(f"\n[red]Error:[/red] {error}")
267
- return
268
-
269
- # ...rest of existing function code...
1
+ from pathlib import Path
2
+ from typing import Set
3
+ import ast
4
+ import yaml
5
+ import subprocess
6
+ import sys
7
+ import os
8
+
9
+ class Validator:
10
+ def __init__(self, preview_dir: Path):
11
+ self.preview_dir = preview_dir
12
+ self.validated_files: Set[Path] = set()
13
+
14
+ def validate_python_syntax(self, filepath: Path):
15
+ """Validate Python file syntax using ast."""
16
+ try:
17
+ with open(filepath, 'r', encoding="utf-8") as file:
18
+ content = file.read()
19
+ ast.parse(content, filename=str(filepath))
20
+ except SyntaxError as e:
21
+ raise ValueError(f"Python syntax error in {filepath}: {e}")
22
+ except Exception as e:
23
+ raise ValueError(f"Error validating {filepath}: {e}")
24
+
25
+ def validate_files(self, files: Set[Path]):
26
+ """Validate all modified files."""
27
+ for filepath in files:
28
+ full_path = self.preview_dir / filepath
29
+ if not full_path.exists():
30
+ raise ValueError(f"File not found after changes: {filepath}")
31
+
32
+ if filepath.suffix == '.py':
33
+ self.validate_python_syntax(full_path)
34
+
35
+ self.validated_files.add(filepath)
36
+ from rich import print as rprint
37
+ rprint(f"[green]✓[/green] Validated [cyan]{filepath}[/cyan]")
38
+
39
+ def run_tests(self):
40
+ """Run tests if configured in janito.yaml."""
41
+ config_file = self.preview_dir / 'janito.yaml'
42
+ if not config_file.exists():
43
+ print("No test configuration found")
44
+ return
45
+
46
+ try:
47
+ with open(config_file) as f:
48
+ config = yaml.safe_load(f)
49
+
50
+ test_cmd = config.get('test_cmd')
51
+ if not test_cmd:
52
+ print("No test_cmd found in configuration")
53
+ return
54
+
55
+ print(f"Running test command: {test_cmd}")
56
+
57
+ # Save current directory
58
+ original_dir = Path.cwd()
59
+ try:
60
+ # Change to preview directory
61
+ os.chdir(self.preview_dir)
62
+ # Run the test command
63
+ exit_code = os.system(test_cmd)
64
+ finally:
65
+ # Restore original directory
66
+ os.chdir(original_dir)
67
+
68
+ if exit_code != 0:
69
+ raise ValueError(f"Test command failed with exit code {exit_code}")
70
+
71
+ from rich.panel import Panel
72
+ from rich import print as rprint
73
+
74
+ # Create a summary panel
75
+ validated_files_list = "\n".join([f"[cyan]• {f}[/cyan]" for f in sorted(self.validated_files)])
76
+ summary = Panel(
77
+ f"[green]✓[/green] All files validated successfully:\n\n{validated_files_list}",
78
+ title="[bold green]Validation Summary[/bold green]",
79
+ border_style="green"
80
+ )
81
+ rprint("\n" + summary + "\n")
82
+ print("Tests completed successfully")
83
+
84
+ except yaml.YAMLError as e:
85
+ raise ValueError(f"Error parsing janito.yaml: {e}")
86
+ except Exception as e:
87
+ raise ValueError(f"Error running tests: {e}")
@@ -0,0 +1,63 @@
1
+ from typing import Optional
2
+ from pathlib import Path
3
+ from rich.syntax import Syntax
4
+
5
+
6
+ # Mapping of file extensions to syntax lexer names
7
+ FILE_EXTENSION_MAP = {
8
+ '.py': 'python',
9
+ '.js': 'javascript',
10
+ '.ts': 'typescript',
11
+ '.html': 'html',
12
+ '.css': 'css',
13
+ '.json': 'json',
14
+ '.md': 'markdown',
15
+ '.yaml': 'yaml',
16
+ '.yml': 'yaml',
17
+ '.sh': 'bash',
18
+ '.bash': 'bash',
19
+ '.sql': 'sql',
20
+ '.xml': 'xml',
21
+ '.cpp': 'cpp',
22
+ '.c': 'c',
23
+ '.h': 'cpp',
24
+ '.hpp': 'cpp',
25
+ '.java': 'java',
26
+ '.go': 'go',
27
+ '.rs': 'rust',
28
+ }
29
+
30
+ def get_file_syntax(filepath: Path) -> Optional[str]:
31
+ """Get syntax lexer name based on file extension.
32
+
33
+ Args:
34
+ filepath: Path object containing the file path
35
+
36
+ Returns:
37
+ String containing the syntax lexer name or None if not found
38
+ """
39
+ return ext_map.get(filepath.suffix.lower())
40
+
41
+ def create_content_preview(filepath: Path, content: str, is_new: bool = False) -> Syntax:
42
+ """Create a preview with syntax highlighting using consistent styling
43
+
44
+ Args:
45
+ filepath: Path to the file being previewed
46
+ content: Content to preview
47
+ is_new: Whether this is a new file preview
48
+
49
+ Returns:
50
+ Syntax highlighted content
51
+ """
52
+ # Get file info
53
+ syntax_type = get_file_syntax(filepath)
54
+
55
+ # Create syntax highlighted content
56
+ return Syntax(
57
+ content,
58
+ syntax_type or "text",
59
+ theme="monokai",
60
+ line_numbers=True,
61
+ word_wrap=True,
62
+ tab_size=4
63
+ )
@@ -1,43 +1,44 @@
1
- from typing import List, Tuple
2
- from difflib import SequenceMatcher
3
-
4
- def find_common_sections(search_lines: List[str], replace_lines: List[str]) -> Tuple[List[str], List[str], List[str], List[str], List[str]]:
5
- """Find common sections between search and replace content"""
6
- # Find common lines from top
7
- common_top = []
8
- for s, r in zip(search_lines, replace_lines):
9
- if s == r:
10
- common_top.append(s)
11
- else:
12
- break
13
-
14
- # Find common lines from bottom
15
- search_remaining = search_lines[len(common_top):]
16
- replace_remaining = replace_lines[len(common_top):]
17
-
18
- common_bottom = []
19
- for s, r in zip(reversed(search_remaining), reversed(replace_remaining)):
20
- if s == r:
21
- common_bottom.insert(0, s)
22
- else:
23
- break
24
-
25
- # Get the unique middle sections
26
- search_middle = search_remaining[:-len(common_bottom)] if common_bottom else search_remaining
27
- replace_middle = replace_remaining[:-len(common_bottom)] if common_bottom else replace_remaining
28
-
29
- return common_top, search_middle, replace_middle, common_bottom, search_lines
30
-
31
- def get_line_similarity(line1: str, line2: str) -> float:
32
- """Calculate similarity ratio between two lines"""
33
- return SequenceMatcher(None, line1, line2).ratio()
34
-
35
- def find_similar_lines(deleted_lines: List[str], added_lines: List[str], similarity_threshold: float = 0.5) -> List[Tuple[int, int, float]]:
36
- """Find similar lines between deleted and added content"""
37
- similar_pairs = []
38
- for i, del_line in enumerate(deleted_lines):
39
- for j, add_line in enumerate(added_lines):
40
- similarity = get_line_similarity(del_line, add_line)
41
- if similarity >= similarity_threshold:
42
- similar_pairs.append((i, j, similarity))
43
- return similar_pairs
1
+ from typing import List, Tuple
2
+ from difflib import SequenceMatcher
3
+
4
+ def find_common_sections(search_lines: List[str], replace_lines: List[str]) -> Tuple[List[str], List[str], List[str], List[str], List[str]]:
5
+ """Find common sections between search and replace content"""
6
+ # Find common lines from top
7
+ common_top = []
8
+ for s, r in zip(search_lines, replace_lines):
9
+ if s == r:
10
+ common_top.append(s)
11
+ else:
12
+ break
13
+
14
+ # Find common lines from bottom
15
+ search_remaining = search_lines[len(common_top):]
16
+ replace_remaining = replace_lines[len(common_top):]
17
+
18
+ common_bottom = []
19
+ for s, r in zip(reversed(search_remaining), reversed(replace_remaining)):
20
+ if s == r:
21
+ common_bottom.insert(0, s)
22
+ else:
23
+ break
24
+
25
+ # Get the unique middle sections
26
+ search_middle = search_remaining[:-len(common_bottom)] if common_bottom else search_remaining
27
+ replace_middle = replace_remaining[:-len(common_bottom)] if common_bottom else replace_remaining
28
+
29
+ return common_top, search_middle, replace_middle, common_bottom, search_lines
30
+
31
+
32
+ def find_similar_lines(deleted_lines: List[str], added_lines: List[str], similarity_threshold: float = 0.5) -> List[Tuple[int, int, float]]:
33
+ """Find similar lines between deleted and added content"""
34
+ similar_pairs = []
35
+ for i, del_line in enumerate(deleted_lines):
36
+ for j, add_line in enumerate(added_lines):
37
+ similarity = get_line_similarity(del_line, add_line)
38
+ if similarity >= similarity_threshold:
39
+ similar_pairs.append((i, j, similarity))
40
+ return similar_pairs
41
+
42
+ def get_line_similarity(line1: str, line2: str) -> float:
43
+ """Calculate similarity ratio between two lines"""
44
+ return SequenceMatcher(None, line1, line2).ratio()