janito 1.9.0__py3-none-any.whl → 1.10.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 (81) hide show
  1. janito/__init__.py +1 -1
  2. janito/agent/api_exceptions.py +4 -0
  3. janito/agent/config.py +1 -1
  4. janito/agent/config_defaults.py +2 -26
  5. janito/agent/conversation.py +163 -122
  6. janito/agent/conversation_api.py +149 -159
  7. janito/agent/{conversation_history.py → llm_conversation_history.py} +18 -1
  8. janito/agent/openai_client.py +38 -23
  9. janito/agent/openai_schema_generator.py +162 -129
  10. janito/agent/platform_discovery.py +134 -77
  11. janito/agent/profile_manager.py +5 -5
  12. janito/agent/rich_message_handler.py +80 -31
  13. janito/agent/templates/profiles/system_prompt_template_base.txt.j2 +5 -4
  14. janito/agent/test_openai_schema_generator.py +93 -0
  15. janito/agent/tool_base.py +7 -2
  16. janito/agent/tool_executor.py +54 -49
  17. janito/agent/tool_registry.py +5 -2
  18. janito/agent/tool_use_tracker.py +26 -5
  19. janito/agent/tools/__init__.py +6 -3
  20. janito/agent/tools/create_directory.py +3 -1
  21. janito/agent/tools/create_file.py +7 -1
  22. janito/agent/tools/fetch_url.py +40 -3
  23. janito/agent/tools/find_files.py +3 -1
  24. janito/agent/tools/get_file_outline/core.py +6 -7
  25. janito/agent/tools/get_file_outline/search_outline.py +3 -1
  26. janito/agent/tools/get_lines.py +7 -2
  27. janito/agent/tools/move_file.py +3 -1
  28. janito/agent/tools/present_choices.py +3 -1
  29. janito/agent/tools/python_command_runner.py +150 -0
  30. janito/agent/tools/python_file_runner.py +148 -0
  31. janito/agent/tools/python_stdin_runner.py +154 -0
  32. janito/agent/tools/remove_directory.py +3 -1
  33. janito/agent/tools/remove_file.py +5 -1
  34. janito/agent/tools/replace_file.py +12 -2
  35. janito/agent/tools/replace_text_in_file.py +4 -2
  36. janito/agent/tools/run_bash_command.py +30 -69
  37. janito/agent/tools/run_powershell_command.py +134 -105
  38. janito/agent/tools/search_text.py +172 -122
  39. janito/agent/tools/validate_file_syntax/core.py +3 -1
  40. janito/agent/tools_utils/action_type.py +7 -0
  41. janito/agent/tools_utils/dir_walk_utils.py +3 -2
  42. janito/agent/tools_utils/formatting.py +47 -21
  43. janito/agent/tools_utils/gitignore_utils.py +66 -40
  44. janito/agent/tools_utils/test_gitignore_utils.py +46 -0
  45. janito/cli/_print_config.py +63 -61
  46. janito/cli/arg_parser.py +13 -12
  47. janito/cli/cli_main.py +137 -147
  48. janito/cli/main.py +152 -174
  49. janito/cli/one_shot.py +40 -26
  50. janito/i18n/__init__.py +1 -1
  51. janito/rich_utils.py +46 -8
  52. janito/shell/commands/__init__.py +2 -4
  53. janito/shell/commands/conversation_restart.py +3 -1
  54. janito/shell/commands/edit.py +3 -0
  55. janito/shell/commands/history_view.py +3 -3
  56. janito/shell/commands/lang.py +3 -0
  57. janito/shell/commands/livelogs.py +5 -3
  58. janito/shell/commands/prompt.py +6 -0
  59. janito/shell/commands/session.py +3 -0
  60. janito/shell/commands/session_control.py +3 -0
  61. janito/shell/commands/termweb_log.py +8 -0
  62. janito/shell/commands/tools.py +3 -0
  63. janito/shell/commands/track.py +36 -0
  64. janito/shell/commands/utility.py +13 -18
  65. janito/shell/commands/verbose.py +3 -4
  66. janito/shell/input_history.py +62 -0
  67. janito/shell/main.py +117 -181
  68. janito/shell/session/manager.py +0 -21
  69. janito/shell/ui/interactive.py +0 -2
  70. janito/termweb/static/editor.css +0 -4
  71. janito/tests/test_rich_utils.py +44 -0
  72. janito/web/app.py +0 -75
  73. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/METADATA +61 -42
  74. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/RECORD +78 -71
  75. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/WHEEL +1 -1
  76. janito/agent/providers.py +0 -77
  77. janito/agent/tools/run_python_command.py +0 -161
  78. janito/shell/commands/sum.py +0 -49
  79. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/entry_points.txt +0 -0
  80. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/licenses/LICENSE +0 -0
  81. {janito-1.9.0.dist-info → janito-1.10.0.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,11 @@
1
1
  from janito.agent.tool_base import ToolBase
2
+ from janito.agent.tools_utils.action_type import ActionType
2
3
  from janito.agent.tool_registry import register_tool
3
4
  from janito.agent.tools_utils.utils import pluralize
4
5
  from janito.i18n import tr
5
6
  import os
6
7
  import re
7
- from janito.agent.tools_utils.gitignore_utils import filter_ignored
8
+ from janito.agent.tools_utils.gitignore_utils import GitignoreFilter
8
9
 
9
10
 
10
11
  def is_binary_file(path, blocksize=1024):
@@ -28,12 +29,13 @@ def is_binary_file(path, blocksize=1024):
28
29
  class SearchTextTool(ToolBase):
29
30
  """
30
31
  Search for a text pattern (regex or plain string) in all files within one or more directories or file paths and return matching lines. Respects .gitignore.
31
-
32
32
  Args:
33
33
  paths (str): String of one or more paths (space-separated) to search in. Each path can be a directory or a file.
34
34
  pattern (str): Regex pattern or plain text substring to search for in files. Must not be empty. Tries regex first, falls back to substring if regex is invalid.
35
35
  Note: When using regex mode, special characters (such as [, ], ., *, etc.) must be escaped if you want to match them literally (e.g., use '\\[DEBUG\\]' to match the literal string '[DEBUG]').
36
- is_regex (bool): If True, treat pattern as regex. If False, treat as plain text. Defaults to False.
36
+ is_regex (bool): If True, treat pattern as a regular expression. If False, treat as plain text (default).
37
+ Only set is_regex=True if your pattern is a valid regular expression. Do NOT set is_regex=True for plain text patterns, as regex special characters (such as ., *, [, ], etc.) will be interpreted and may cause unexpected results.
38
+ For plain text substring search, leave is_regex as False or omit it.
37
39
  max_depth (int, optional): Maximum directory depth to search. If 0 (default), search is recursive with no depth limit. If >0, limits recursion to that depth. Setting max_depth=1 disables recursion (only top-level directory). Ignored for file paths.
38
40
  max_results (int): Maximum number of results to return. 0 means no limit (default).
39
41
  ignore_utf8_errors (bool): If True, ignore utf-8 decode errors. Defaults to True.
@@ -42,20 +44,16 @@ class SearchTextTool(ToolBase):
42
44
  If max_results is reached, appends a note to the output.
43
45
  """
44
46
 
45
- def run(
46
- self,
47
- paths: str,
48
- pattern: str,
49
- is_regex: bool = False,
50
- max_depth: int = 0,
51
- max_results: int = 0,
52
- ignore_utf8_errors: bool = True,
53
- ) -> str:
47
+ def _prepare_pattern(self, pattern, is_regex):
54
48
  if not pattern:
55
49
  self.report_error(
56
50
  tr("Error: Empty search pattern provided. Operation aborted.")
57
51
  )
58
- return tr("Error: Empty search pattern provided. Operation aborted.")
52
+ return (
53
+ None,
54
+ False,
55
+ tr("Error: Empty search pattern provided. Operation aborted."),
56
+ )
59
57
  regex = None
60
58
  use_regex = False
61
59
  if is_regex:
@@ -63,9 +61,11 @@ class SearchTextTool(ToolBase):
63
61
  regex = re.compile(pattern)
64
62
  use_regex = True
65
63
  except re.error as e:
66
- self.report_warning(tr("⚠️ Invalid regex pattern."))
67
- return tr(
68
- "Warning: Invalid regex pattern: {error}. No results.", error=e
64
+ self.report_warning(tr("\u26a0\ufe0f Invalid regex pattern."))
65
+ return (
66
+ None,
67
+ False,
68
+ tr("Warning: Invalid regex pattern: {error}. No results.", error=e),
69
69
  )
70
70
  else:
71
71
  try:
@@ -74,116 +74,100 @@ class SearchTextTool(ToolBase):
74
74
  except re.error:
75
75
  regex = None
76
76
  use_regex = False
77
- output = []
78
- limit_reached = False
79
- total_results = 0
80
- paths_list = paths.split()
81
- for search_path in paths_list:
82
- from janito.agent.tools_utils.utils import display_path
77
+ return regex, use_regex, None
83
78
 
84
- info_str = tr(
85
- "🔍 Searching for {search_type} '{pattern}' in '{disp_path}'",
86
- search_type=("text-regex" if use_regex else "text"),
87
- pattern=pattern,
88
- disp_path=display_path(search_path),
89
- )
90
- if max_depth > 0:
91
- info_str += tr(" [max_depth={max_depth}]", max_depth=max_depth)
92
- self.report_info(info_str)
93
- dir_output = []
94
- dir_limit_reached = False
95
- if os.path.isfile(search_path):
96
- # Handle single file
97
- path = search_path
98
- if not is_binary_file(path):
99
- try:
100
- open_kwargs = {"mode": "r", "encoding": "utf-8"}
101
- if ignore_utf8_errors:
102
- open_kwargs["errors"] = "ignore"
103
- with open(path, **open_kwargs) as f:
104
- for lineno, line in enumerate(f, 1):
105
- if use_regex:
106
- if regex.search(line):
107
- dir_output.append(
108
- f"{path}:{lineno}: {line.strip()}"
109
- )
110
- else:
111
- if pattern in line:
112
- dir_output.append(
113
- f"{path}:{lineno}: {line.strip()}"
114
- )
115
- if (
116
- max_results > 0
117
- and (total_results + len(dir_output)) >= max_results
118
- ):
119
- dir_limit_reached = True
120
- break
121
- except Exception:
122
- pass
123
- output.extend(dir_output)
124
- total_results += len(dir_output)
125
- if dir_limit_reached:
126
- limit_reached = True
127
- break
128
- continue
129
- # Directory logic as before
130
- if max_depth == 1:
131
- walk_result = next(os.walk(search_path), None)
132
- if walk_result is None:
133
- walker = [(search_path, [], [])]
134
- else:
135
- _, dirs, files = walk_result
136
- dirs, files = filter_ignored(search_path, dirs, files)
137
- walker = [(search_path, dirs, files)]
79
+ def _search_file(
80
+ self,
81
+ path,
82
+ pattern,
83
+ regex,
84
+ use_regex,
85
+ max_results,
86
+ total_results,
87
+ ignore_utf8_errors,
88
+ ):
89
+ dir_output = []
90
+ dir_limit_reached = False
91
+ if not is_binary_file(path):
92
+ try:
93
+ open_kwargs = {"mode": "r", "encoding": "utf-8"}
94
+ if ignore_utf8_errors:
95
+ open_kwargs["errors"] = "ignore"
96
+ with open(path, **open_kwargs) as f:
97
+ for lineno, line in enumerate(f, 1):
98
+ if use_regex:
99
+ if regex.search(line):
100
+ dir_output.append(f"{path}:{lineno}: {line.strip()}")
101
+ else:
102
+ if pattern in line:
103
+ dir_output.append(f"{path}:{lineno}: {line.strip()}")
104
+ if (
105
+ max_results > 0
106
+ and (total_results + len(dir_output)) >= max_results
107
+ ):
108
+ dir_limit_reached = True
109
+ break
110
+ except Exception:
111
+ pass
112
+ return dir_output, dir_limit_reached
113
+
114
+ def _search_directory(
115
+ self,
116
+ search_path,
117
+ pattern,
118
+ regex,
119
+ use_regex,
120
+ max_depth,
121
+ max_results,
122
+ total_results,
123
+ ignore_utf8_errors,
124
+ ):
125
+ dir_output = []
126
+ dir_limit_reached = False
127
+ if max_depth == 1:
128
+ walk_result = next(os.walk(search_path), None)
129
+ if walk_result is None:
130
+ walker = [(search_path, [], [])]
138
131
  else:
139
- walker = os.walk(search_path)
140
- stop_search = False
141
- for root, dirs, files in walker:
132
+ _, dirs, files = walk_result
133
+ gitignore = GitignoreFilter()
134
+ dirs, files = gitignore.filter_ignored(search_path, dirs, files)
135
+ walker = [(search_path, dirs, files)]
136
+ else:
137
+ gitignore = GitignoreFilter()
138
+ walker = os.walk(search_path)
139
+ stop_search = False
140
+ for root, dirs, files in walker:
141
+ if stop_search:
142
+ break
143
+ rel_path = os.path.relpath(root, search_path)
144
+ depth = 0 if rel_path == "." else rel_path.count(os.sep) + 1
145
+ if max_depth == 1 and depth > 0:
146
+ break
147
+ if max_depth > 0 and depth > max_depth:
148
+ continue
149
+ dirs, files = gitignore.filter_ignored(root, dirs, files)
150
+ for filename in files:
142
151
  if stop_search:
143
152
  break
144
- rel_path = os.path.relpath(root, search_path)
145
- depth = 0 if rel_path == "." else rel_path.count(os.sep) + 1
146
- if max_depth == 1 and depth > 0:
153
+ path = os.path.join(root, filename)
154
+ file_output, file_limit_reached = self._search_file(
155
+ path,
156
+ pattern,
157
+ regex,
158
+ use_regex,
159
+ max_results,
160
+ total_results + len(dir_output),
161
+ ignore_utf8_errors,
162
+ )
163
+ dir_output.extend(file_output)
164
+ if file_limit_reached:
165
+ dir_limit_reached = True
166
+ stop_search = True
147
167
  break
148
- if max_depth > 0 and depth > max_depth:
149
- continue
150
- dirs, files = filter_ignored(root, dirs, files)
151
- for filename in files:
152
- if stop_search:
153
- break
154
- path = os.path.join(root, filename)
155
- if is_binary_file(path):
156
- continue
157
- try:
158
- open_kwargs = {"mode": "r", "encoding": "utf-8"}
159
- if ignore_utf8_errors:
160
- open_kwargs["errors"] = "ignore"
161
- with open(path, **open_kwargs) as f:
162
- for lineno, line in enumerate(f, 1):
163
- if use_regex:
164
- if regex.search(line):
165
- dir_output.append(
166
- f"{path}:{lineno}: {line.strip()}"
167
- )
168
- else:
169
- if pattern in line:
170
- dir_output.append(
171
- f"{path}:{lineno}: {line.strip()}"
172
- )
173
- if (
174
- max_results > 0
175
- and (total_results + len(dir_output)) >= max_results
176
- ):
177
- dir_limit_reached = True
178
- stop_search = True
179
- break
180
- except Exception:
181
- continue
182
- output.extend(dir_output)
183
- total_results += len(dir_output)
184
- if dir_limit_reached:
185
- limit_reached = True
186
- break
168
+ return dir_output, dir_limit_reached
169
+
170
+ def _format_result(self, pattern, use_regex, output, limit_reached):
187
171
  header = tr(
188
172
  "[search_text] Pattern: '{pattern}' | Regex: {use_regex} | Results: {count}",
189
173
  pattern=pattern,
@@ -195,10 +179,76 @@ class SearchTextTool(ToolBase):
195
179
  result += tr("\n[Note: max_results limit reached, output truncated.]")
196
180
  self.report_success(
197
181
  tr(
198
- " {count} {line_word}{limit}",
182
+ " \u2705 {count} {line_word}{limit}",
199
183
  count=len(output),
200
184
  line_word=pluralize("line", len(output)),
201
185
  limit=(" (limit reached)" if limit_reached else ""),
202
186
  )
203
187
  )
204
188
  return result
189
+
190
+ def run(
191
+ self,
192
+ paths: str,
193
+ pattern: str,
194
+ is_regex: bool = False,
195
+ max_depth: int = 0,
196
+ max_results: int = 0,
197
+ ignore_utf8_errors: bool = True,
198
+ ) -> str:
199
+ regex, use_regex, error_msg = self._prepare_pattern(pattern, is_regex)
200
+ if error_msg:
201
+ return error_msg
202
+ paths_list = paths.split()
203
+ results = []
204
+ total_results = 0
205
+ limit_reached = False
206
+ for search_path in paths_list:
207
+ from janito.agent.tools_utils.utils import display_path
208
+
209
+ info_str = tr(
210
+ "\U0001f50d Searching for {search_type} '{pattern}' in '{disp_path}'",
211
+ search_type=("regex" if use_regex else "text"),
212
+ pattern=pattern,
213
+ disp_path=display_path(search_path),
214
+ )
215
+ if max_depth > 0:
216
+ info_str += tr(" [max_depth={max_depth}]", max_depth=max_depth)
217
+ self.report_info(ActionType.READ, info_str)
218
+ dir_output = []
219
+ dir_limit_reached = False
220
+ if os.path.isfile(search_path):
221
+ dir_output, dir_limit_reached = self._search_file(
222
+ search_path,
223
+ pattern,
224
+ regex,
225
+ use_regex,
226
+ max_results,
227
+ total_results,
228
+ ignore_utf8_errors,
229
+ )
230
+ total_results += len(dir_output)
231
+ if dir_limit_reached:
232
+ limit_reached = True
233
+ else:
234
+ dir_output, dir_limit_reached = self._search_directory(
235
+ search_path,
236
+ pattern,
237
+ regex,
238
+ use_regex,
239
+ max_depth,
240
+ max_results,
241
+ total_results,
242
+ ignore_utf8_errors,
243
+ )
244
+ total_results += len(dir_output)
245
+ if dir_limit_reached:
246
+ limit_reached = True
247
+ # Format and append result for this path
248
+ result_str = self._format_result(
249
+ pattern, use_regex, dir_output, dir_limit_reached
250
+ )
251
+ results.append(info_str + "\n" + result_str)
252
+ if limit_reached:
253
+ break
254
+ return "\n\n".join(results)
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  from janito.i18n import tr
3
3
  from janito.agent.tool_base import ToolBase
4
+ from janito.agent.tools_utils.action_type import ActionType
4
5
  from janito.agent.tool_registry import register_tool
5
6
  from janito.agent.tools_utils.utils import display_path
6
7
 
@@ -77,7 +78,8 @@ class ValidateFileSyntaxTool(ToolBase):
77
78
  def run(self, file_path: str) -> str:
78
79
  disp_path = display_path(file_path)
79
80
  self.report_info(
80
- tr("🔎 Validating syntax for file '{disp_path}' ...", disp_path=disp_path)
81
+ ActionType.READ,
82
+ tr("🔎 Validating syntax for file '{disp_path}' ...", disp_path=disp_path),
81
83
  )
82
84
  result = validate_file_syntax(
83
85
  file_path,
@@ -0,0 +1,7 @@
1
+ from enum import Enum, auto
2
+
3
+
4
+ class ActionType(Enum):
5
+ READ = auto()
6
+ WRITE = auto()
7
+ EXECUTE = auto()
@@ -1,5 +1,5 @@
1
1
  import os
2
- from .gitignore_utils import filter_ignored
2
+ from .gitignore_utils import GitignoreFilter
3
3
 
4
4
 
5
5
  def walk_dir_with_gitignore(root_dir, max_depth=None):
@@ -11,6 +11,7 @@ def walk_dir_with_gitignore(root_dir, max_depth=None):
11
11
  - If max_depth=1, only the root directory (matches 'find . -maxdepth 1').
12
12
  - If max_depth=N (N>1), yields files in root and up to N-1 levels below root (matches 'find . -maxdepth N').
13
13
  """
14
+ gitignore = GitignoreFilter()
14
15
  for root, dirs, files in os.walk(root_dir):
15
16
  rel_path = os.path.relpath(root, root_dir)
16
17
  depth = 0 if rel_path == "." else rel_path.count(os.sep) + 1
@@ -19,5 +20,5 @@ def walk_dir_with_gitignore(root_dir, max_depth=None):
19
20
  # For max_depth=1, only root (depth=0). For max_depth=2, root and one level below (depth=0,1).
20
21
  if depth > 0:
21
22
  continue
22
- dirs, files = filter_ignored(root, dirs, files)
23
+ dirs, files = gitignore.filter_ignored(root, dirs, files)
23
24
  yield root, dirs, files
@@ -1,23 +1,49 @@
1
- def format_outline_table(outline_items):
2
- if not outline_items:
3
- return "No classes, functions, or variables found."
4
- header = "| Type | Name | Start | End | Parent | Docstring |\n|---------|-------------|-------|-----|----------|--------------------------|"
5
- rows = []
6
- for item in outline_items:
7
- docstring = item.get("docstring", "").replace("\n", " ")
8
- if len(docstring) > 24:
9
- docstring = docstring[:21] + "..."
10
- rows.append(
11
- f"| {item['type']:<7} | {item['name']:<11} | {item['start']:<5} | {item['end']:<3} | {item['parent']:<8} | {docstring:<24} |"
12
- )
13
- return header + "\n" + "\n".join(rows)
1
+ class OutlineFormatter:
2
+ """
3
+ Utility class for formatting code and markdown outlines into human-readable tables.
4
+ """
14
5
 
6
+ @staticmethod
7
+ def format_outline_table(outline_items):
8
+ """
9
+ Format a list of code outline items (classes, functions, variables) into a table.
15
10
 
16
- def format_markdown_outline_table(outline_items):
17
- if not outline_items:
18
- return "No headers found."
19
- header = "| Level | Header | Line |\n|-------|----------------------------------|------|"
20
- rows = []
21
- for item in outline_items:
22
- rows.append(f"| {item['level']:<5} | {item['title']:<32} | {item['line']:<4} |")
23
- return header + "\n" + "\n".join(rows)
11
+ Args:
12
+ outline_items (list of dict): Each dict should contain keys: 'type', 'name', 'start', 'end', 'parent', 'docstring'.
13
+
14
+ Returns:
15
+ str: Formatted table as a string.
16
+ """
17
+ if not outline_items:
18
+ return "No classes, functions, or variables found."
19
+ header = "| Type | Name | Start | End | Parent | Docstring |\n|---------|-------------|-------|-----|----------|--------------------------|"
20
+ rows = []
21
+ for item in outline_items:
22
+ docstring = item.get("docstring", "").replace("\n", " ")
23
+ if len(docstring) > 24:
24
+ docstring = docstring[:21] + "..."
25
+ rows.append(
26
+ f"| {item['type']:<7} | {item['name']:<11} | {item['start']:<5} | {item['end']:<3} | {item['parent']:<8} | {docstring:<24} |"
27
+ )
28
+ return header + "\n" + "\n".join(rows)
29
+
30
+ @staticmethod
31
+ def format_markdown_outline_table(outline_items):
32
+ """
33
+ Format a list of markdown outline items (headers) into a table.
34
+
35
+ Args:
36
+ outline_items (list of dict): Each dict should contain keys: 'level', 'title', 'line'.
37
+
38
+ Returns:
39
+ str: Formatted table as a string.
40
+ """
41
+ if not outline_items:
42
+ return "No headers found."
43
+ header = "| Level | Header | Line |\n|-------|----------------------------------|------|"
44
+ rows = []
45
+ for item in outline_items:
46
+ rows.append(
47
+ f"| {item['level']:<5} | {item['title']:<32} | {item['line']:<4} |"
48
+ )
49
+ return header + "\n" + "\n".join(rows)
@@ -1,43 +1,69 @@
1
1
  import os
2
2
  import pathspec
3
3
 
4
- _spec = None
5
-
6
-
7
- def load_gitignore_patterns(gitignore_path=".gitignore"):
8
- global _spec
9
- if not os.path.exists(gitignore_path):
10
- _spec = pathspec.PathSpec.from_lines("gitwildmatch", [])
11
- return _spec
12
- with open(gitignore_path, "r", encoding="utf-8", errors="replace") as f:
13
- lines = f.readlines()
14
- _spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
15
- return _spec
16
-
17
-
18
- def is_ignored(path):
19
- global _spec
20
- if _spec is None:
21
- _spec = load_gitignore_patterns()
22
- # Normalize path to be relative and use forward slashes
23
- rel_path = os.path.relpath(path).replace(os.sep, "/")
24
- return _spec.match_file(rel_path)
25
-
26
-
27
- def filter_ignored(root, dirs, files, spec=None):
28
- if spec is None:
29
- global _spec
30
- if _spec is None:
31
- _spec = load_gitignore_patterns()
32
- spec = _spec
33
-
34
- def not_ignored(p):
35
- rel_path = os.path.relpath(os.path.join(root, p)).replace(os.sep, "/")
36
- # Always ignore .git directory (like git does)
37
- if rel_path == ".git" or rel_path.startswith(".git/"):
38
- return False
39
- return not spec.match_file(rel_path)
40
-
41
- dirs[:] = [d for d in dirs if not_ignored(d)]
42
- files = [f for f in files if not_ignored(f)]
43
- return dirs, files
4
+
5
+ class GitignoreFilter:
6
+ """
7
+ Utility class for loading, interpreting, and applying .gitignore patterns to file and directory paths.
8
+
9
+ Methods
10
+ -------
11
+ __init__(self, gitignore_path: str = ".gitignore")
12
+ Loads and parses .gitignore patterns from the specified path.
13
+
14
+ is_ignored(self, path: str) -> bool
15
+ Returns True if the given path matches any of the loaded .gitignore patterns.
16
+
17
+ filter_ignored(self, root: str, dirs: list, files: list) -> tuple[list, list]
18
+ Filters out ignored directories and files from the provided lists, returning only those not ignored.
19
+ """
20
+
21
+ def __init__(self, gitignore_path: str = ".gitignore"):
22
+ self.gitignore_path = os.path.abspath(gitignore_path)
23
+ self.base_dir = os.path.dirname(self.gitignore_path)
24
+ lines = []
25
+ if not os.path.exists(self.gitignore_path):
26
+ self._spec = pathspec.PathSpec.from_lines("gitwildmatch", [])
27
+ else:
28
+ with open(
29
+ self.gitignore_path, "r", encoding="utf-8", errors="replace"
30
+ ) as f:
31
+ lines = f.readlines()
32
+ self._spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
33
+ # Collect directory patterns (ending with /)
34
+ self.dir_patterns = [
35
+ line.strip() for line in lines if line.strip().endswith("/")
36
+ ]
37
+
38
+ def is_ignored(self, path: str) -> bool:
39
+ """Return True if the given path is ignored by the loaded .gitignore patterns."""
40
+ abs_path = os.path.abspath(path)
41
+ rel_path = os.path.relpath(abs_path, self.base_dir).replace(os.sep, "/")
42
+ return self._spec.match_file(rel_path)
43
+
44
+ def filter_ignored(self, root: str, dirs: list, files: list) -> tuple[list, list]:
45
+ """
46
+ Filter out ignored directories and files from the provided lists.
47
+ Always ignores the .git directory (like git does).
48
+ """
49
+
50
+ def dir_is_ignored(d):
51
+ abs_path = os.path.abspath(os.path.join(root, d))
52
+ rel_path = os.path.relpath(abs_path, self.base_dir).replace(os.sep, "/")
53
+ if rel_path == ".git" or rel_path.startswith(".git/"):
54
+ return True
55
+ # Remove directory if it matches a directory pattern
56
+ for pat in self.dir_patterns:
57
+ pat_clean = pat.rstrip("/")
58
+ if rel_path == pat_clean or rel_path.startswith(pat_clean + "/"):
59
+ return True
60
+ return self._spec.match_file(rel_path)
61
+
62
+ def file_is_ignored(f):
63
+ abs_path = os.path.abspath(os.path.join(root, f))
64
+ rel_path = os.path.relpath(abs_path, self.base_dir).replace(os.sep, "/")
65
+ return self._spec.match_file(rel_path)
66
+
67
+ dirs[:] = [d for d in dirs if not dir_is_ignored(d)]
68
+ files = [f for f in files if not file_is_ignored(f)]
69
+ return dirs, files
@@ -0,0 +1,46 @@
1
+ import os
2
+ import tempfile
3
+ import shutil
4
+ import pytest
5
+ from janito.agent.tools_utils.gitignore_utils import GitignoreFilter
6
+
7
+
8
+ def test_gitignore_filter_basic(tmp_path):
9
+ # Create a .gitignore file
10
+ gitignore_content = """
11
+ ignored_file.txt
12
+ ignored_dir/
13
+ *.log
14
+ """
15
+ gitignore_path = tmp_path / ".gitignore"
16
+ gitignore_path.write_text(gitignore_content)
17
+
18
+ # Create files and directories
19
+ (tmp_path / "ignored_file.txt").write_text("should be ignored")
20
+ (tmp_path / "not_ignored.txt").write_text("should not be ignored")
21
+ (tmp_path / "ignored_dir").mkdir()
22
+ (tmp_path / "ignored_dir" / "file.txt").write_text("should be ignored")
23
+ (tmp_path / "not_ignored_dir").mkdir()
24
+ (tmp_path / "not_ignored_dir" / "file.txt").write_text("should not be ignored")
25
+ (tmp_path / "file.log").write_text("should be ignored")
26
+
27
+ gi = GitignoreFilter(str(gitignore_path))
28
+
29
+ assert gi.is_ignored(str(tmp_path / "ignored_file.txt"))
30
+ assert not gi.is_ignored(str(tmp_path / "not_ignored.txt"))
31
+ # Directory itself is not ignored, only its contents
32
+ assert not gi.is_ignored(str(tmp_path / "ignored_dir"))
33
+ assert gi.is_ignored(str(tmp_path / "ignored_dir" / "file.txt"))
34
+ assert not gi.is_ignored(str(tmp_path / "not_ignored_dir"))
35
+ assert not gi.is_ignored(str(tmp_path / "not_ignored_dir" / "file.txt"))
36
+ assert gi.is_ignored(str(tmp_path / "file.log"))
37
+
38
+ # Test filter_ignored
39
+ dirs = ["ignored_dir", "not_ignored_dir"]
40
+ files = ["ignored_file.txt", "not_ignored.txt", "file.log"]
41
+ filtered_dirs, filtered_files = gi.filter_ignored(str(tmp_path), dirs, files)
42
+ assert "ignored_dir" not in filtered_dirs
43
+ assert "not_ignored_dir" in filtered_dirs
44
+ assert "ignored_file.txt" not in filtered_files
45
+ assert "file.log" not in filtered_files
46
+ assert "not_ignored.txt" in filtered_files