indent 0.0.8__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 indent might be problematic. Click here for more details.

Files changed (56) hide show
  1. exponent/__init__.py +1 -0
  2. exponent/cli.py +112 -0
  3. exponent/commands/cloud_commands.py +85 -0
  4. exponent/commands/common.py +434 -0
  5. exponent/commands/config_commands.py +581 -0
  6. exponent/commands/github_app_commands.py +211 -0
  7. exponent/commands/listen_commands.py +96 -0
  8. exponent/commands/run_commands.py +208 -0
  9. exponent/commands/settings.py +56 -0
  10. exponent/commands/shell_commands.py +2840 -0
  11. exponent/commands/theme.py +246 -0
  12. exponent/commands/types.py +111 -0
  13. exponent/commands/upgrade.py +29 -0
  14. exponent/commands/utils.py +236 -0
  15. exponent/core/config.py +180 -0
  16. exponent/core/graphql/__init__.py +0 -0
  17. exponent/core/graphql/client.py +59 -0
  18. exponent/core/graphql/cloud_config_queries.py +77 -0
  19. exponent/core/graphql/get_chats_query.py +47 -0
  20. exponent/core/graphql/github_config_queries.py +56 -0
  21. exponent/core/graphql/mutations.py +75 -0
  22. exponent/core/graphql/queries.py +110 -0
  23. exponent/core/graphql/subscriptions.py +452 -0
  24. exponent/core/remote_execution/checkpoints.py +212 -0
  25. exponent/core/remote_execution/cli_rpc_types.py +214 -0
  26. exponent/core/remote_execution/client.py +545 -0
  27. exponent/core/remote_execution/code_execution.py +58 -0
  28. exponent/core/remote_execution/command_execution.py +105 -0
  29. exponent/core/remote_execution/error_info.py +45 -0
  30. exponent/core/remote_execution/exceptions.py +10 -0
  31. exponent/core/remote_execution/file_write.py +410 -0
  32. exponent/core/remote_execution/files.py +415 -0
  33. exponent/core/remote_execution/git.py +268 -0
  34. exponent/core/remote_execution/languages/python_execution.py +239 -0
  35. exponent/core/remote_execution/languages/shell_streaming.py +221 -0
  36. exponent/core/remote_execution/languages/types.py +20 -0
  37. exponent/core/remote_execution/session.py +128 -0
  38. exponent/core/remote_execution/system_context.py +54 -0
  39. exponent/core/remote_execution/tool_execution.py +289 -0
  40. exponent/core/remote_execution/truncation.py +284 -0
  41. exponent/core/remote_execution/types.py +670 -0
  42. exponent/core/remote_execution/utils.py +600 -0
  43. exponent/core/types/__init__.py +0 -0
  44. exponent/core/types/command_data.py +206 -0
  45. exponent/core/types/event_types.py +89 -0
  46. exponent/core/types/generated/__init__.py +0 -0
  47. exponent/core/types/generated/strategy_info.py +225 -0
  48. exponent/migration-docs/login.md +112 -0
  49. exponent/py.typed +4 -0
  50. exponent/utils/__init__.py +0 -0
  51. exponent/utils/colors.py +92 -0
  52. exponent/utils/version.py +289 -0
  53. indent-0.0.8.dist-info/METADATA +36 -0
  54. indent-0.0.8.dist-info/RECORD +56 -0
  55. indent-0.0.8.dist-info/WHEEL +4 -0
  56. indent-0.0.8.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,415 @@
1
+ import os
2
+ from asyncio import gather, to_thread
3
+ from typing import Final, cast
4
+
5
+ from anyio import Path as AsyncPath
6
+ from python_ripgrep import PySortMode, PySortModeKind, files, search
7
+ from rapidfuzz import process
8
+
9
+ from exponent.core.remote_execution.types import (
10
+ FileAttachment,
11
+ FilePath,
12
+ GetAllTrackedFilesRequest,
13
+ GetAllTrackedFilesResponse,
14
+ GetFileAttachmentRequest,
15
+ GetFileAttachmentResponse,
16
+ GetFileAttachmentsRequest,
17
+ GetFileAttachmentsResponse,
18
+ GetMatchingFilesRequest,
19
+ GetMatchingFilesResponse,
20
+ ListFilesRequest,
21
+ ListFilesResponse,
22
+ RemoteFile,
23
+ )
24
+ from exponent.core.remote_execution.utils import safe_read_file
25
+
26
+ MAX_MATCHING_FILES: Final[int] = 10
27
+ FILE_NOT_FOUND: Final[str] = "File {} does not exist"
28
+ MAX_FILES_TO_WALK: Final[int] = 10_000
29
+
30
+ GLOB_MAX_COUNT: Final[int] = 1000
31
+
32
+
33
+ class FileCache:
34
+ """A cache of the files in a working directory.
35
+
36
+ Args:
37
+ working_directory: The working directory to cache the files from.
38
+ """
39
+
40
+ def __init__(self, working_directory: str) -> None:
41
+ self.working_directory = working_directory
42
+ self._cache: list[str] | None = None
43
+
44
+ async def get_files(self) -> list[str]:
45
+ """Get the files in the working directory.
46
+
47
+ Returns:
48
+ A list of file paths in the working directory.
49
+ """
50
+ if self._cache is None:
51
+ self._cache = await file_walk(self.working_directory)
52
+
53
+ return self._cache
54
+
55
+
56
+ async def list_files(list_files_request: ListFilesRequest) -> ListFilesResponse:
57
+ """Get a list of files in the specified directory.
58
+
59
+ Args:
60
+ list_files_request: An object containing the directory to list files from.
61
+
62
+ Returns:
63
+ A list of RemoteFile objects representing the files in the directory.
64
+ """
65
+
66
+ filenames = [
67
+ entry.name async for entry in AsyncPath(list_files_request.directory).iterdir()
68
+ ]
69
+
70
+ return ListFilesResponse(
71
+ files=[
72
+ RemoteFile(
73
+ file_path=filename,
74
+ working_directory=list_files_request.directory,
75
+ )
76
+ for filename in filenames
77
+ ],
78
+ correlation_id=list_files_request.correlation_id,
79
+ )
80
+
81
+
82
+ async def get_file_content(
83
+ absolute_path: FilePath, offset: int | None = None, limit: int | None = None
84
+ ) -> tuple[str, bool]:
85
+ """Get the content of the file at the specified path.
86
+
87
+ Args:
88
+ absolute_path: The absolute path to the file.
89
+
90
+ Returns:
91
+ A tuple containing the content of the file and a boolean indicating if the file exists.
92
+ """
93
+ file = AsyncPath(absolute_path)
94
+ exists = await file.exists()
95
+
96
+ if not exists:
97
+ return FILE_NOT_FOUND.format(absolute_path), False
98
+
99
+ if await file.is_dir():
100
+ return "File is a directory", True
101
+
102
+ content = await safe_read_file(file)
103
+
104
+ if offset or limit:
105
+ offset = offset or 0
106
+ limit = limit or -1
107
+
108
+ content_lines = content.splitlines()
109
+ content_lines = content_lines[offset:]
110
+ content_lines = content_lines[:limit]
111
+
112
+ content = "\n".join(content_lines)
113
+
114
+ return content, exists
115
+
116
+
117
+ async def get_file_attachments(
118
+ get_file_attachments_request: GetFileAttachmentsRequest,
119
+ client_working_directory: str,
120
+ ) -> GetFileAttachmentsResponse:
121
+ """Get the content of the files at the specified paths.
122
+
123
+ Args:
124
+ get_file_attachments_request: An object containing the file paths.
125
+ client_working_directory: The working directory of the client.
126
+
127
+ Returns:
128
+ A list of FileAttachment objects containing the content of the files.
129
+ """
130
+ remote_files = get_file_attachments_request.files
131
+ attachments = await gather(
132
+ *[
133
+ get_file_content(
134
+ AsyncPath(client_working_directory) / remote_file.file_path
135
+ )
136
+ for remote_file in remote_files
137
+ ]
138
+ )
139
+
140
+ files = [
141
+ FileAttachment(attachment_type="file", file=remote_file, content=content)
142
+ for remote_file, (content, _) in zip(remote_files, attachments)
143
+ ]
144
+
145
+ return GetFileAttachmentsResponse(
146
+ correlation_id=get_file_attachments_request.correlation_id,
147
+ file_attachments=files,
148
+ )
149
+
150
+
151
+ async def get_file_attachment(
152
+ get_file_attachment_request: GetFileAttachmentRequest, client_working_directory: str
153
+ ) -> GetFileAttachmentResponse:
154
+ """Get the content of the file at the specified path.
155
+
156
+ Args:
157
+ get_file_attachment_request: An object containing the file path.
158
+ client_working_directory: The working directory of the client.
159
+
160
+ Returns:
161
+ A FileAttachment object containing the content of the file.
162
+ """
163
+ file = get_file_attachment_request.file
164
+ absolute_path = await file.resolve(client_working_directory)
165
+
166
+ content, exists = await get_file_content(absolute_path)
167
+
168
+ return GetFileAttachmentResponse(
169
+ content=content,
170
+ exists=exists,
171
+ file=file,
172
+ correlation_id=get_file_attachment_request.correlation_id,
173
+ )
174
+
175
+
176
+ async def get_matching_files(
177
+ search_term: GetMatchingFilesRequest,
178
+ file_cache: FileCache,
179
+ ) -> GetMatchingFilesResponse:
180
+ """Get the files that match the search term.
181
+
182
+ Args:
183
+ search_term: The search term to match against the files.
184
+ file_cache: A cache of the files in the working directory.
185
+
186
+ Returns:
187
+ A list of RemoteFile objects that match the search term.
188
+ """
189
+ # Use rapidfuzz to find the best matching files
190
+ matching_files = await to_thread(
191
+ process.extract,
192
+ search_term.search_term,
193
+ await file_cache.get_files(),
194
+ limit=MAX_MATCHING_FILES,
195
+ score_cutoff=0,
196
+ )
197
+
198
+ directory = file_cache.working_directory
199
+ files: list[RemoteFile] = [
200
+ RemoteFile(file_path=file, working_directory=directory)
201
+ for file, _, _ in matching_files
202
+ ]
203
+
204
+ return GetMatchingFilesResponse(
205
+ files=files,
206
+ correlation_id=search_term.correlation_id,
207
+ )
208
+
209
+
210
+ async def get_all_tracked_files(
211
+ request: GetAllTrackedFilesRequest,
212
+ working_directory: str,
213
+ ) -> GetAllTrackedFilesResponse:
214
+ return GetAllTrackedFilesResponse(
215
+ correlation_id=request.correlation_id,
216
+ files=await get_all_non_ignored_files(working_directory),
217
+ )
218
+
219
+
220
+ async def search_files(
221
+ path_str: str,
222
+ file_pattern: str | None,
223
+ regex: str,
224
+ working_directory: str,
225
+ ) -> list[str]:
226
+ path = AsyncPath(working_directory) / path_str
227
+ path_resolved = await path.resolve()
228
+ globs = [file_pattern] if file_pattern else None
229
+
230
+ return await to_thread(
231
+ search,
232
+ patterns=[regex],
233
+ paths=[str(path_resolved)],
234
+ globs=globs,
235
+ after_context=3,
236
+ before_context=5,
237
+ heading=True,
238
+ separator_field_context="|",
239
+ separator_field_match="|",
240
+ separator_context="\n...\n",
241
+ )
242
+
243
+
244
+ async def get_all_file_contents(
245
+ working_directory: str,
246
+ ) -> list[list[str]]:
247
+ path_resolved = await AsyncPath(working_directory).resolve()
248
+
249
+ results = await to_thread(
250
+ search,
251
+ patterns=[".*"],
252
+ paths=[str(path_resolved)],
253
+ globs=["!**/poetry.lock", "!**/pnpm-lock.yaml"],
254
+ heading=True,
255
+ line_number=False,
256
+ )
257
+
258
+ result_sizes = [len(result) for result in results]
259
+ total_size = sum(result_sizes)
260
+ batch_size = total_size // 10
261
+
262
+ batches = []
263
+ current_batch: list[str] = []
264
+ current_size = 0
265
+
266
+ for i, result in enumerate(results):
267
+ if current_size + result_sizes[i] > batch_size:
268
+ batches.append(current_batch)
269
+ current_batch = []
270
+ current_size = 0
271
+
272
+ current_batch.append(result)
273
+ current_size += result_sizes[i]
274
+
275
+ batches.append(current_batch)
276
+
277
+ return batches
278
+
279
+
280
+ async def normalize_files(
281
+ working_directory: str, file_paths: list[FilePath]
282
+ ) -> list[RemoteFile]:
283
+ """Normalize file paths to be relative to the working directory.
284
+
285
+ Args:
286
+ working_directory: The working directory to normalize the file paths against.
287
+ file_paths: A list of file paths to normalize.
288
+
289
+ Returns:
290
+ A list of RemoteFile objects with normalized file paths.
291
+ """
292
+ working_path = await AsyncPath(working_directory).resolve()
293
+ normalized_files = []
294
+
295
+ for file_path in file_paths:
296
+ path = AsyncPath(file_path)
297
+
298
+ if path.is_absolute():
299
+ path = path.relative_to(working_path)
300
+
301
+ normalized_files.append(
302
+ RemoteFile(
303
+ file_path=str(path),
304
+ working_directory=working_directory,
305
+ )
306
+ )
307
+
308
+ return sorted(normalized_files)
309
+
310
+
311
+ def _format_ignore_globs(ignore_extra: list[str] | None) -> list[str]:
312
+ if ignore_extra is None:
313
+ return []
314
+
315
+ return [f"!**/{ignore}" for ignore in ignore_extra]
316
+
317
+
318
+ async def file_walk(
319
+ directory: str,
320
+ ignore_extra: list[str] | None = None,
321
+ max_files: int = MAX_FILES_TO_WALK,
322
+ ) -> list[str]:
323
+ """
324
+ Walk through a directory and return all file paths, respecting .gitignore and additional ignore patterns.
325
+
326
+ Args:
327
+ directory: The directory to walk through
328
+ ignore_extra: Additional directory paths to ignore, follows the gitignore format.
329
+ max_files: The maximal number of files to return
330
+
331
+ Returns:
332
+ A list of file paths in the directory.
333
+ """
334
+ working_path = str(await AsyncPath(directory).resolve())
335
+
336
+ results: list[str] = await to_thread(
337
+ files,
338
+ patterns=[""],
339
+ paths=[working_path],
340
+ globs=_format_ignore_globs(ignore_extra),
341
+ sort=PySortMode(kind=PySortModeKind.Path),
342
+ max_count=max_files,
343
+ )
344
+
345
+ # Create relative paths using os.path functions which handle platform differences
346
+ relative_results = []
347
+ for result in results:
348
+ # Check if the path is inside the working directory
349
+ if os.path.commonpath([working_path, result]) == working_path:
350
+ # Create relative path
351
+ rel_path = os.path.relpath(result, working_path)
352
+ relative_results.append(rel_path)
353
+ else:
354
+ # Fallback to just using the filename
355
+ relative_results.append(os.path.basename(result))
356
+
357
+ return relative_results
358
+
359
+
360
+ async def get_all_non_ignored_files(working_directory: str) -> list[RemoteFile]:
361
+ file_paths = await file_walk(working_directory, ignore_extra=DEFAULT_IGNORES)
362
+
363
+ return await normalize_files(working_directory, cast(list[FilePath], file_paths))
364
+
365
+
366
+ async def glob(
367
+ path: str,
368
+ glob_pattern: str,
369
+ ) -> list[str]:
370
+ return await to_thread(
371
+ files,
372
+ patterns=[],
373
+ paths=[path],
374
+ globs=[glob_pattern],
375
+ sort=PySortMode(kind=PySortModeKind.Path),
376
+ max_count=GLOB_MAX_COUNT,
377
+ )
378
+
379
+
380
+ DEFAULT_IGNORES = [
381
+ "**/.git/",
382
+ ".venv/",
383
+ ".mypy_cache",
384
+ ".pytest_cache",
385
+ "node_modules/",
386
+ "venv/",
387
+ ".pyenv",
388
+ "__pycache__",
389
+ ".ipynb_checkpoints",
390
+ ".vercel",
391
+ "__pycache__/",
392
+ "*.py[cod]",
393
+ "*$py.class",
394
+ ".env",
395
+ "*.so",
396
+ ".Python",
397
+ "build/",
398
+ "develop-eggs/",
399
+ "dist/",
400
+ "downloads/",
401
+ "eggs/",
402
+ ".eggs/",
403
+ "lib/",
404
+ "lib64/",
405
+ "parts/",
406
+ "sdist/",
407
+ "var/",
408
+ "wheels/",
409
+ "pip-wheel-metadata/",
410
+ "share/python-wheels/",
411
+ "*.egg-info/",
412
+ ".installed.cfg",
413
+ "*.egg",
414
+ "MANIFEST",
415
+ ]
@@ -0,0 +1,268 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import pygit2
7
+ from anyio import Path as AsyncPath
8
+ from gitignore_parser import (
9
+ IgnoreRule,
10
+ handle_negation,
11
+ parse_gitignore,
12
+ rule_from_pattern,
13
+ )
14
+ from pygit2 import Tree
15
+ from pygit2.enums import DiffOption
16
+ from pygit2.repository import Repository
17
+
18
+ from exponent.core.remote_execution.types import (
19
+ GitInfo,
20
+ )
21
+ from exponent.core.remote_execution.utils import safe_read_file
22
+
23
+ GIT_OBJ_COMMIT = 1
24
+
25
+
26
+ async def git_file_walk(
27
+ repo: Repository,
28
+ directory: str,
29
+ ) -> list[str]:
30
+ """
31
+ Walk through a directory and return all file paths, respecting .gitignore and additional ignore patterns.
32
+ """
33
+ tree = get_git_subtree_for_dir(repo, directory)
34
+
35
+ if not tree:
36
+ return []
37
+
38
+ # diff to the empty tree to see all files
39
+ tracked_diff = tree.diff_to_tree()
40
+
41
+ tracked_files = [delta.new_file.path for delta in tracked_diff.deltas]
42
+
43
+ # Find untracked files relative to the root
44
+ untracked_diff = repo.diff(flags=DiffOption.INCLUDE_UNTRACKED)
45
+ untracked_files_from_root = [
46
+ AsyncPath(delta.new_file.path) for delta in untracked_diff.deltas
47
+ ]
48
+
49
+ # Current working directory relative to the repo root
50
+ dir_path = await AsyncPath(directory).resolve()
51
+ repo_path = await AsyncPath(repo.workdir).resolve()
52
+
53
+ if repo_path == dir_path:
54
+ relative_directory = str(repo_path)
55
+ else:
56
+ relative_directory = str(dir_path.relative_to(repo_path))
57
+
58
+ # Resolve all untracked files that are within the current working directory
59
+ untracked_files = []
60
+ for untracked_file in untracked_files_from_root:
61
+ if not untracked_file.is_relative_to(relative_directory):
62
+ continue
63
+
64
+ untracked_files.append(str(untracked_file.relative_to(relative_directory)))
65
+
66
+ # Combine both as sets to remove duplicates
67
+ return list(set(tracked_files) | set(untracked_files))
68
+
69
+
70
+ def get_repo(working_directory: str) -> Repository | None:
71
+ try:
72
+ return Repository(working_directory)
73
+ except pygit2.GitError:
74
+ return None
75
+
76
+
77
+ async def get_git_info(working_directory: str) -> GitInfo | None:
78
+ try:
79
+ repo = Repository(working_directory)
80
+ except pygit2.GitError:
81
+ return None
82
+
83
+ return GitInfo(
84
+ branch=(await _get_git_branch(repo)) or "<unknown branch>",
85
+ remote=_get_git_remote(repo),
86
+ )
87
+
88
+
89
+ def get_tracked_files_in_dir(
90
+ repo: Repository,
91
+ dir: str | Path,
92
+ filter_func: Callable[[str], bool] | None = None,
93
+ ) -> list[str]:
94
+ rel_path = get_path_relative_to_repo_root(repo, dir)
95
+ dir_tree = get_git_subtree_for_dir(repo, dir)
96
+ entries: list[str] = []
97
+ if not dir_tree:
98
+ return entries
99
+ for entry in dir_tree:
100
+ if not entry.name:
101
+ continue
102
+ entry_path = str(Path(f"{repo.workdir}/{rel_path}/{entry.name}"))
103
+ if entry.type_str == "tree":
104
+ entries.extend(get_tracked_files_in_dir(repo, entry_path, filter_func))
105
+ elif entry.type_str == "blob":
106
+ if not filter_func or filter_func(entry.name):
107
+ entries.append(entry_path)
108
+ return entries
109
+
110
+
111
+ def get_git_subtree_for_dir(repo: Repository, dir: str | Path) -> Tree | None:
112
+ rel_path = get_path_relative_to_repo_root(repo, dir)
113
+
114
+ try:
115
+ head_commit = repo.head.peel(GIT_OBJ_COMMIT)
116
+ except pygit2.GitError:
117
+ # If the repo is empty, then the head commit will not exist
118
+ return None
119
+ head_tree: Tree = head_commit.tree
120
+
121
+ if rel_path == Path("."):
122
+ # If the relative path is the root of the repo, then
123
+ # the head_tree is what we want. Note we do this because
124
+ # Passing "." or "" as the path into the tree will raise.
125
+ return head_tree
126
+ return cast(Tree, head_tree[str(rel_path)])
127
+
128
+
129
+ def get_path_relative_to_repo_root(repo: Repository, path: str | Path) -> Path:
130
+ path = Path(path).resolve()
131
+ return path.relative_to(Path(repo.workdir).resolve())
132
+
133
+
134
+ def get_local_commit_hash() -> str:
135
+ try:
136
+ # Open the repository (assumes the current working directory is within the git repo)
137
+ repo = Repository(os.getcwd())
138
+
139
+ # Get the current HEAD commit
140
+ head = repo.head
141
+
142
+ # Get the commit object and return its hash as a string
143
+ return str(repo[head.target].id)
144
+ except pygit2.GitError:
145
+ return "unknown-local-commit"
146
+
147
+
148
+ def _get_git_remote(repo: Repository) -> str | None:
149
+ if repo.remotes:
150
+ return str(repo.remotes[0].url)
151
+ return None
152
+
153
+
154
+ async def _get_git_branch(repo: Repository) -> str | None:
155
+ try:
156
+ # Look for HEAD file in the .git directory
157
+ head_path = AsyncPath(os.path.join(repo.path, "HEAD"))
158
+
159
+ if not await head_path.exists():
160
+ return None
161
+
162
+ head_content_raw = await safe_read_file(head_path)
163
+ head_content = head_content_raw.strip()
164
+
165
+ if head_content.startswith("ref:"):
166
+ return head_content.split("refs/heads/")[-1]
167
+ else:
168
+ return None
169
+
170
+ except Exception: # noqa: BLE001
171
+ return None
172
+
173
+
174
+ class GitIgnoreHandler:
175
+ def __init__(
176
+ self, working_directory: str, default_ignores: list[str] | None = None
177
+ ):
178
+ self.checkers = {}
179
+
180
+ if default_ignores:
181
+ self.checkers[working_directory] = self._parse_ignore_extra(
182
+ working_directory, default_ignores
183
+ )
184
+
185
+ async def read_ignorefile(self, path: str) -> None:
186
+ new_ignore = await self._get_ignored_checker(path)
187
+
188
+ if new_ignore:
189
+ self.checkers[path] = new_ignore
190
+
191
+ def filter(
192
+ self,
193
+ relpaths: list[str],
194
+ root: str,
195
+ ) -> list[str]:
196
+ result = []
197
+
198
+ for relpath in relpaths:
199
+ if relpath.startswith(".git"):
200
+ continue
201
+
202
+ path = os.path.join(root, relpath)
203
+
204
+ if self.is_ignored(path):
205
+ continue
206
+
207
+ result.append(relpath)
208
+
209
+ return result
210
+
211
+ def is_ignored(self, path: str) -> bool:
212
+ return any(
213
+ self.checkers[dp](path)
214
+ for dp in self.checkers
215
+ if self._is_subpath(path, dp)
216
+ )
217
+
218
+ def _parse_ignore_extra(
219
+ self, working_directory: str, ignore_extra: list[str]
220
+ ) -> Callable[[str], bool]:
221
+ rules: list[IgnoreRule] = []
222
+
223
+ for pattern in ignore_extra:
224
+ if (
225
+ rule := rule_from_pattern(pattern, base_path=working_directory)
226
+ ) is not None:
227
+ rules.append(rule)
228
+
229
+ def rule_handler(file_path: str) -> bool:
230
+ nonlocal rules
231
+ return bool(handle_negation(file_path, rules))
232
+
233
+ return rule_handler
234
+
235
+ async def _get_ignored_checker(self, dir_path: str) -> Callable[[str], bool] | None:
236
+ new_ignore = self._parse_gitignore(dir_path)
237
+
238
+ existing_ignore = self.checkers.get(dir_path)
239
+
240
+ if existing_ignore and new_ignore:
241
+ return self._or(new_ignore, existing_ignore)
242
+
243
+ return new_ignore or existing_ignore
244
+
245
+ @staticmethod
246
+ def _parse_gitignore(directory: str) -> Callable[[str], bool] | None:
247
+ gitignore_path = os.path.join(directory, ".gitignore")
248
+
249
+ if os.path.isfile(gitignore_path):
250
+ return cast(Callable[[str], bool], parse_gitignore(gitignore_path))
251
+
252
+ return None
253
+
254
+ @staticmethod
255
+ def _or(
256
+ a: Callable[[str], bool], b: Callable[[str], bool]
257
+ ) -> Callable[[str], bool]:
258
+ def or_handler(file_path: str) -> bool:
259
+ return a(file_path) or b(file_path)
260
+
261
+ return or_handler
262
+
263
+ @staticmethod
264
+ def _is_subpath(path: str, parent: str) -> bool:
265
+ """
266
+ Check if a path is a subpath of another path.
267
+ """
268
+ return os.path.commonpath([path, parent]) == parent