clink-cli 0.2.1__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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
utils/file_utils.py
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File reading utilities with directory support and token management
|
|
3
|
+
|
|
4
|
+
This module provides secure file access functionality for the MCP server.
|
|
5
|
+
It implements critical security measures to prevent unauthorized file access
|
|
6
|
+
and manages token limits to ensure efficient API usage.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Path validation and sandboxing to prevent directory traversal attacks
|
|
10
|
+
- Support for both individual files and recursive directory reading
|
|
11
|
+
- Token counting and management to stay within API limits
|
|
12
|
+
- Automatic file type detection and filtering
|
|
13
|
+
- Comprehensive error handling with informative messages
|
|
14
|
+
|
|
15
|
+
Security Model:
|
|
16
|
+
- All file access is restricted to PROJECT_ROOT and its subdirectories
|
|
17
|
+
- Absolute paths are required to prevent ambiguity
|
|
18
|
+
- Symbolic links are resolved to ensure they stay within bounds
|
|
19
|
+
|
|
20
|
+
CONVERSATION MEMORY INTEGRATION:
|
|
21
|
+
This module works with the conversation memory system to support efficient
|
|
22
|
+
multi-turn file handling:
|
|
23
|
+
|
|
24
|
+
1. DEDUPLICATION SUPPORT:
|
|
25
|
+
- File reading functions are called by conversation-aware tools
|
|
26
|
+
- Supports newest-first file prioritization by providing accurate token estimation
|
|
27
|
+
- Enables efficient file content caching and token budget management
|
|
28
|
+
|
|
29
|
+
2. TOKEN BUDGET OPTIMIZATION:
|
|
30
|
+
- Provides accurate token estimation for file content before reading
|
|
31
|
+
- Supports the dual prioritization strategy by enabling precise budget calculations
|
|
32
|
+
- Enables tools to make informed decisions about which files to include
|
|
33
|
+
|
|
34
|
+
3. CROSS-TOOL FILE PERSISTENCE:
|
|
35
|
+
- File reading results are used across different tools in conversation chains
|
|
36
|
+
- Consistent file access patterns support conversation continuation scenarios
|
|
37
|
+
- Error handling preserves conversation flow when files become unavailable
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
from .file_types import BINARY_EXTENSIONS, CODE_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS
|
|
48
|
+
from .security_config import EXCLUDED_DIRS, is_dangerous_path
|
|
49
|
+
from .token_utils import DEFAULT_CONTEXT_WINDOW, estimate_tokens
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_builtin_custom_models_config(path_str: str) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Check if path points to the server's built-in custom_models.json config file.
|
|
55
|
+
|
|
56
|
+
This only matches the server's internal config, not user-specified CUSTOM_MODELS_CONFIG_PATH.
|
|
57
|
+
We identify the built-in config by checking if it resolves to the server's conf directory.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path_str: Path to check
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if this is the server's built-in custom_models.json config file
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
path = Path(path_str)
|
|
67
|
+
|
|
68
|
+
# Get the server root by going up from this file: utils/file_utils.py -> server_root
|
|
69
|
+
server_root = Path(__file__).parent.parent
|
|
70
|
+
builtin_config = server_root / "conf" / "custom_models.json"
|
|
71
|
+
|
|
72
|
+
# Check if the path resolves to the same file as our built-in config
|
|
73
|
+
# This handles both relative and absolute paths to the same file
|
|
74
|
+
return path.resolve() == builtin_config.resolve()
|
|
75
|
+
|
|
76
|
+
except Exception:
|
|
77
|
+
# If path resolution fails, it's not our built-in config
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_mcp_directory(path: Path) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Check if a directory is the MCP server's own directory.
|
|
87
|
+
|
|
88
|
+
This prevents the MCP from including its own code when scanning projects
|
|
89
|
+
where the MCP has been cloned as a subdirectory.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
path: Directory path to check
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if this is the MCP server directory or a subdirectory
|
|
96
|
+
"""
|
|
97
|
+
if not path.is_dir():
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Get the directory where the MCP server is running from
|
|
101
|
+
# __file__ is utils/file_utils.py, so parent.parent is the MCP root
|
|
102
|
+
mcp_server_dir = Path(__file__).parent.parent.resolve()
|
|
103
|
+
|
|
104
|
+
# Check if the given path is the MCP server directory or a subdirectory
|
|
105
|
+
try:
|
|
106
|
+
path.resolve().relative_to(mcp_server_dir)
|
|
107
|
+
logger.info(f"Detected MCP server directory at {path}, will exclude from scanning")
|
|
108
|
+
return True
|
|
109
|
+
except ValueError:
|
|
110
|
+
# Not a subdirectory of MCP server
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_user_home_directory() -> Optional[Path]:
|
|
115
|
+
"""
|
|
116
|
+
Get the user's home directory.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
User's home directory path
|
|
120
|
+
"""
|
|
121
|
+
return Path.home()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_home_directory_root(path: Path) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Check if the given path is the user's home directory root.
|
|
127
|
+
|
|
128
|
+
This prevents scanning the entire home directory which could include
|
|
129
|
+
sensitive data and non-project files.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
path: Directory path to check
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
True if this is the home directory root
|
|
136
|
+
"""
|
|
137
|
+
user_home = get_user_home_directory()
|
|
138
|
+
if not user_home:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
resolved_path = path.resolve()
|
|
143
|
+
resolved_home = user_home.resolve()
|
|
144
|
+
|
|
145
|
+
# Check if this is exactly the home directory
|
|
146
|
+
if resolved_path == resolved_home:
|
|
147
|
+
logger.warning(
|
|
148
|
+
f"Attempted to scan user home directory root: {path}. Please specify a subdirectory instead."
|
|
149
|
+
)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# Also check common home directory patterns
|
|
153
|
+
path_str = str(resolved_path).lower()
|
|
154
|
+
home_patterns = [
|
|
155
|
+
"/users/", # macOS
|
|
156
|
+
"/home/", # Linux
|
|
157
|
+
"c:\\users\\", # Windows
|
|
158
|
+
"c:/users/", # Windows with forward slashes
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
for pattern in home_patterns:
|
|
162
|
+
if pattern in path_str:
|
|
163
|
+
# Extract the user directory path
|
|
164
|
+
# e.g., /Users/fahad or /home/username
|
|
165
|
+
parts = path_str.split(pattern)
|
|
166
|
+
if len(parts) > 1:
|
|
167
|
+
# Get the part after the pattern
|
|
168
|
+
after_pattern = parts[1]
|
|
169
|
+
# Check if we're at the user's root (no subdirectories)
|
|
170
|
+
if "/" not in after_pattern and "\\" not in after_pattern:
|
|
171
|
+
logger.warning(
|
|
172
|
+
f"Attempted to scan user home directory root: {path}. "
|
|
173
|
+
f"Please specify a subdirectory instead."
|
|
174
|
+
)
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.debug(f"Error checking if path is home directory: {e}")
|
|
179
|
+
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def detect_file_type(file_path: str) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Detect file type for appropriate processing strategy.
|
|
186
|
+
|
|
187
|
+
This function is intended for specific file type handling (e.g., image processing,
|
|
188
|
+
binary file analysis, or enhanced file filtering).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
file_path: Path to the file to analyze
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
str: "text", "binary", or "image"
|
|
195
|
+
"""
|
|
196
|
+
path = Path(file_path)
|
|
197
|
+
|
|
198
|
+
# Check extension first (fast)
|
|
199
|
+
extension = path.suffix.lower()
|
|
200
|
+
if extension in TEXT_EXTENSIONS:
|
|
201
|
+
return "text"
|
|
202
|
+
elif extension in IMAGE_EXTENSIONS:
|
|
203
|
+
return "image"
|
|
204
|
+
elif extension in BINARY_EXTENSIONS:
|
|
205
|
+
return "binary"
|
|
206
|
+
|
|
207
|
+
# Fallback: check magic bytes for text vs binary
|
|
208
|
+
# This is helpful for files without extensions or unknown extensions
|
|
209
|
+
try:
|
|
210
|
+
with open(path, "rb") as f:
|
|
211
|
+
chunk = f.read(1024)
|
|
212
|
+
# Simple heuristic: if we can decode as UTF-8, likely text
|
|
213
|
+
chunk.decode("utf-8")
|
|
214
|
+
return "text"
|
|
215
|
+
except UnicodeDecodeError:
|
|
216
|
+
return "binary"
|
|
217
|
+
except (FileNotFoundError, PermissionError) as e:
|
|
218
|
+
logger.warning(f"Could not access file {file_path} for type detection: {e}")
|
|
219
|
+
return "unknown"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def should_add_line_numbers(file_path: str, include_line_numbers: Optional[bool] = None) -> bool:
|
|
223
|
+
"""
|
|
224
|
+
Determine if line numbers should be added to a file.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
file_path: Path to the file
|
|
228
|
+
include_line_numbers: Explicit preference, or None for auto-detection
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
bool: True if line numbers should be added
|
|
232
|
+
"""
|
|
233
|
+
if include_line_numbers is not None:
|
|
234
|
+
return include_line_numbers
|
|
235
|
+
|
|
236
|
+
# Default: DO NOT add line numbers
|
|
237
|
+
# Tools that want line numbers must explicitly request them
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _normalize_line_endings(content: str) -> str:
|
|
242
|
+
"""
|
|
243
|
+
Normalize line endings for consistent line numbering.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
content: File content with potentially mixed line endings
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
str: Content with normalized LF line endings
|
|
250
|
+
"""
|
|
251
|
+
# Normalize all line endings to LF for consistent counting
|
|
252
|
+
return content.replace("\r\n", "\n").replace("\r", "\n")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_line_numbers(content: str) -> str:
|
|
256
|
+
"""
|
|
257
|
+
Add line numbers to text content for precise referencing.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
content: Text content to number
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
str: Content with line numbers in format " 45│ actual code line"
|
|
264
|
+
Supports files up to 99,999 lines with dynamic width allocation
|
|
265
|
+
"""
|
|
266
|
+
# Normalize line endings first
|
|
267
|
+
normalized_content = _normalize_line_endings(content)
|
|
268
|
+
lines = normalized_content.split("\n")
|
|
269
|
+
|
|
270
|
+
# Dynamic width allocation based on total line count
|
|
271
|
+
# This supports files of any size by computing required width
|
|
272
|
+
total_lines = len(lines)
|
|
273
|
+
width = len(str(total_lines))
|
|
274
|
+
width = max(width, 4) # Minimum padding for readability
|
|
275
|
+
|
|
276
|
+
# Format with dynamic width and clear separator
|
|
277
|
+
numbered_lines = [f"{i + 1:{width}d}│ {line}" for i, line in enumerate(lines)]
|
|
278
|
+
|
|
279
|
+
return "\n".join(numbered_lines)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def resolve_and_validate_path(path_str: str) -> Path:
|
|
283
|
+
"""
|
|
284
|
+
Resolves and validates a path against security policies.
|
|
285
|
+
|
|
286
|
+
This function ensures safe file access by:
|
|
287
|
+
1. Requiring absolute paths (no ambiguity)
|
|
288
|
+
2. Resolving symlinks to prevent deception
|
|
289
|
+
3. Blocking access to dangerous system directories
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
path_str: Path string (must be absolute)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Resolved Path object that is safe to access
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ValueError: If path is not absolute or otherwise invalid
|
|
299
|
+
PermissionError: If path is in a dangerous location
|
|
300
|
+
"""
|
|
301
|
+
# Step 1: Create a Path object
|
|
302
|
+
user_path = Path(path_str)
|
|
303
|
+
|
|
304
|
+
# Step 2: Security Policy - Require absolute paths
|
|
305
|
+
# Relative paths could be interpreted differently depending on working directory
|
|
306
|
+
if not user_path.is_absolute():
|
|
307
|
+
raise ValueError(f"Relative paths are not supported. Please provide an absolute path.\nReceived: {path_str}")
|
|
308
|
+
|
|
309
|
+
# Step 3: Resolve the absolute path (follows symlinks, removes .. and .)
|
|
310
|
+
# This is critical for security as it reveals the true destination of symlinks
|
|
311
|
+
resolved_path = user_path.resolve()
|
|
312
|
+
|
|
313
|
+
# Step 4: Check against dangerous paths
|
|
314
|
+
if is_dangerous_path(resolved_path):
|
|
315
|
+
logger.warning(f"Access denied - dangerous path: {resolved_path}")
|
|
316
|
+
raise PermissionError(f"Access to system directory denied: {path_str}")
|
|
317
|
+
|
|
318
|
+
# Step 5: Check if it's the home directory root
|
|
319
|
+
if is_home_directory_root(resolved_path):
|
|
320
|
+
raise PermissionError(
|
|
321
|
+
f"Cannot scan entire home directory: {path_str}\n" f"Please specify a subdirectory within your home folder."
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return resolved_path
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def expand_paths(paths: list[str], extensions: Optional[set[str]] = None) -> list[str]:
|
|
328
|
+
"""
|
|
329
|
+
Expand paths to individual files, handling both files and directories.
|
|
330
|
+
|
|
331
|
+
This function recursively walks directories to find all matching files.
|
|
332
|
+
It automatically filters out hidden files and common non-code directories
|
|
333
|
+
like __pycache__ to avoid including generated or system files.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
paths: List of file or directory paths (must be absolute)
|
|
337
|
+
extensions: Optional set of file extensions to include (defaults to CODE_EXTENSIONS)
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
List of individual file paths, sorted for consistent ordering
|
|
341
|
+
"""
|
|
342
|
+
if extensions is None:
|
|
343
|
+
extensions = CODE_EXTENSIONS
|
|
344
|
+
|
|
345
|
+
expanded_files = []
|
|
346
|
+
seen = set()
|
|
347
|
+
|
|
348
|
+
for path in paths:
|
|
349
|
+
try:
|
|
350
|
+
# Validate each path for security before processing
|
|
351
|
+
path_obj = resolve_and_validate_path(path)
|
|
352
|
+
except (ValueError, PermissionError):
|
|
353
|
+
# Skip invalid paths silently to allow partial success
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
if not path_obj.exists():
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Safety checks for directory scanning
|
|
360
|
+
if path_obj.is_dir():
|
|
361
|
+
# Check 1: Prevent scanning user's home directory root
|
|
362
|
+
if is_home_directory_root(path_obj):
|
|
363
|
+
logger.warning(f"Skipping home directory root: {path}. Please specify a project subdirectory instead.")
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Check 2: Skip if this is the MCP's own directory
|
|
367
|
+
if is_mcp_directory(path_obj):
|
|
368
|
+
logger.info(
|
|
369
|
+
f"Skipping MCP server directory: {path}. The MCP server code is excluded from project scans."
|
|
370
|
+
)
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
if path_obj.is_file():
|
|
374
|
+
# Add file directly
|
|
375
|
+
if str(path_obj) not in seen:
|
|
376
|
+
expanded_files.append(str(path_obj))
|
|
377
|
+
seen.add(str(path_obj))
|
|
378
|
+
|
|
379
|
+
elif path_obj.is_dir():
|
|
380
|
+
# Walk directory recursively to find all files
|
|
381
|
+
for root, dirs, files in os.walk(path_obj):
|
|
382
|
+
# Filter directories in-place to skip hidden and excluded directories
|
|
383
|
+
# This prevents descending into .git, .venv, __pycache__, node_modules, etc.
|
|
384
|
+
original_dirs = dirs[:]
|
|
385
|
+
dirs[:] = []
|
|
386
|
+
for d in original_dirs:
|
|
387
|
+
# Skip hidden directories
|
|
388
|
+
if d.startswith("."):
|
|
389
|
+
continue
|
|
390
|
+
# Skip excluded directories
|
|
391
|
+
if d in EXCLUDED_DIRS:
|
|
392
|
+
continue
|
|
393
|
+
# Skip MCP directories found during traversal
|
|
394
|
+
dir_path = Path(root) / d
|
|
395
|
+
if is_mcp_directory(dir_path):
|
|
396
|
+
logger.debug(f"Skipping MCP directory during traversal: {dir_path}")
|
|
397
|
+
continue
|
|
398
|
+
dirs.append(d)
|
|
399
|
+
|
|
400
|
+
for file in files:
|
|
401
|
+
# Skip hidden files (e.g., .DS_Store, .gitignore)
|
|
402
|
+
if file.startswith("."):
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
file_path = Path(root) / file
|
|
406
|
+
|
|
407
|
+
# Filter by extension if specified
|
|
408
|
+
if not extensions or file_path.suffix.lower() in extensions:
|
|
409
|
+
full_path = str(file_path)
|
|
410
|
+
# Use set to prevent duplicates
|
|
411
|
+
if full_path not in seen:
|
|
412
|
+
expanded_files.append(full_path)
|
|
413
|
+
seen.add(full_path)
|
|
414
|
+
|
|
415
|
+
# Sort for consistent ordering across different runs
|
|
416
|
+
# This makes output predictable and easier to debug
|
|
417
|
+
expanded_files.sort()
|
|
418
|
+
return expanded_files
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def read_file_content(
|
|
422
|
+
file_path: str, max_size: int = 1_000_000, *, include_line_numbers: Optional[bool] = None
|
|
423
|
+
) -> tuple[str, int]:
|
|
424
|
+
"""
|
|
425
|
+
Read a single file and format it for inclusion in AI prompts.
|
|
426
|
+
|
|
427
|
+
This function handles various error conditions gracefully and always
|
|
428
|
+
returns formatted content, even for errors. This ensures the AI model
|
|
429
|
+
gets context about what files were attempted but couldn't be read.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
file_path: Path to file (must be absolute)
|
|
433
|
+
max_size: Maximum file size to read (default 1MB to prevent memory issues)
|
|
434
|
+
include_line_numbers: Whether to add line numbers. If None, auto-detects based on file type
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Tuple of (formatted_content, estimated_tokens)
|
|
438
|
+
Content is wrapped with clear delimiters for AI parsing
|
|
439
|
+
"""
|
|
440
|
+
logger.debug(f"[FILES] read_file_content called for: {file_path}")
|
|
441
|
+
try:
|
|
442
|
+
# Validate path security before any file operations
|
|
443
|
+
path = resolve_and_validate_path(file_path)
|
|
444
|
+
logger.debug(f"[FILES] Path validated and resolved: {path}")
|
|
445
|
+
except (ValueError, PermissionError) as e:
|
|
446
|
+
# Return error in a format that provides context to the AI
|
|
447
|
+
logger.debug(f"[FILES] Path validation failed for {file_path}: {type(e).__name__}: {e}")
|
|
448
|
+
error_msg = str(e)
|
|
449
|
+
content = f"\n--- ERROR ACCESSING FILE: {file_path} ---\nError: {error_msg}\n--- END FILE ---\n"
|
|
450
|
+
tokens = estimate_tokens(content)
|
|
451
|
+
logger.debug(f"[FILES] Returning error content for {file_path}: {tokens} tokens")
|
|
452
|
+
return content, tokens
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
# Validate file existence and type
|
|
456
|
+
if not path.exists():
|
|
457
|
+
logger.debug(f"[FILES] File does not exist: {file_path}")
|
|
458
|
+
content = f"\n--- FILE NOT FOUND: {file_path} ---\nError: File does not exist\n--- END FILE ---\n"
|
|
459
|
+
return content, estimate_tokens(content)
|
|
460
|
+
|
|
461
|
+
if not path.is_file():
|
|
462
|
+
logger.debug(f"[FILES] Path is not a file: {file_path}")
|
|
463
|
+
content = f"\n--- NOT A FILE: {file_path} ---\nError: Path is not a file\n--- END FILE ---\n"
|
|
464
|
+
return content, estimate_tokens(content)
|
|
465
|
+
|
|
466
|
+
# Check file size to prevent memory exhaustion
|
|
467
|
+
stat_result = path.stat()
|
|
468
|
+
file_size = stat_result.st_size
|
|
469
|
+
logger.debug(f"[FILES] File size for {file_path}: {file_size:,} bytes")
|
|
470
|
+
if file_size > max_size:
|
|
471
|
+
logger.debug(f"[FILES] File too large: {file_path} ({file_size:,} > {max_size:,} bytes)")
|
|
472
|
+
modified_at = datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
473
|
+
content = (
|
|
474
|
+
f"\n--- FILE TOO LARGE: {file_path} (Last modified: {modified_at}) ---\n"
|
|
475
|
+
f"File size: {file_size:,} bytes (max: {max_size:,})\n"
|
|
476
|
+
"--- END FILE ---\n"
|
|
477
|
+
)
|
|
478
|
+
return content, estimate_tokens(content)
|
|
479
|
+
|
|
480
|
+
# Determine if we should add line numbers
|
|
481
|
+
add_line_numbers = should_add_line_numbers(file_path, include_line_numbers)
|
|
482
|
+
logger.debug(f"[FILES] Line numbers for {file_path}: {'enabled' if add_line_numbers else 'disabled'}")
|
|
483
|
+
|
|
484
|
+
# Read the file with UTF-8 encoding, replacing invalid characters
|
|
485
|
+
# This ensures we can handle files with mixed encodings
|
|
486
|
+
logger.debug(f"[FILES] Reading file content for {file_path}")
|
|
487
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
488
|
+
file_content = f.read()
|
|
489
|
+
|
|
490
|
+
logger.debug(f"[FILES] Successfully read {len(file_content)} characters from {file_path}")
|
|
491
|
+
|
|
492
|
+
# Add line numbers if requested or auto-detected
|
|
493
|
+
if add_line_numbers:
|
|
494
|
+
file_content = _add_line_numbers(file_content)
|
|
495
|
+
logger.debug(f"[FILES] Added line numbers to {file_path}")
|
|
496
|
+
else:
|
|
497
|
+
# Still normalize line endings for consistency
|
|
498
|
+
file_content = _normalize_line_endings(file_content)
|
|
499
|
+
|
|
500
|
+
# Format with clear delimiters that help the AI understand file boundaries
|
|
501
|
+
# Using consistent markers makes it easier for the model to parse
|
|
502
|
+
# NOTE: These markers ("--- BEGIN FILE: ... ---") are distinct from git diff markers
|
|
503
|
+
# ("--- BEGIN DIFF: ... ---") to allow AI to distinguish between complete file content
|
|
504
|
+
# vs. partial diff content when files appear in both sections
|
|
505
|
+
modified_at = datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
506
|
+
formatted = (
|
|
507
|
+
f"\n--- BEGIN FILE: {file_path} (Last modified: {modified_at}) ---\n"
|
|
508
|
+
f"{file_content}\n"
|
|
509
|
+
f"--- END FILE: {file_path} ---\n"
|
|
510
|
+
)
|
|
511
|
+
tokens = estimate_tokens(formatted)
|
|
512
|
+
logger.debug(f"[FILES] Formatted content for {file_path}: {len(formatted)} chars, {tokens} tokens")
|
|
513
|
+
return formatted, tokens
|
|
514
|
+
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.debug(f"[FILES] Exception reading file {file_path}: {type(e).__name__}: {e}")
|
|
517
|
+
content = f"\n--- ERROR READING FILE: {file_path} ---\nError: {str(e)}\n--- END FILE ---\n"
|
|
518
|
+
tokens = estimate_tokens(content)
|
|
519
|
+
logger.debug(f"[FILES] Returning error content for {file_path}: {tokens} tokens")
|
|
520
|
+
return content, tokens
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def read_files(
|
|
524
|
+
file_paths: list[str],
|
|
525
|
+
code: Optional[str] = None,
|
|
526
|
+
max_tokens: Optional[int] = None,
|
|
527
|
+
reserve_tokens: int = 50_000,
|
|
528
|
+
*,
|
|
529
|
+
include_line_numbers: bool = False,
|
|
530
|
+
) -> str:
|
|
531
|
+
"""
|
|
532
|
+
Read multiple files and optional direct code with smart token management.
|
|
533
|
+
|
|
534
|
+
This function implements intelligent token budgeting to maximize the amount
|
|
535
|
+
of relevant content that can be included in an AI prompt while staying
|
|
536
|
+
within token limits. It prioritizes direct code and reads files until
|
|
537
|
+
the token budget is exhausted.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
file_paths: List of file or directory paths (absolute paths required)
|
|
541
|
+
code: Optional direct code to include (prioritized over files)
|
|
542
|
+
max_tokens: Maximum tokens to use (defaults to DEFAULT_CONTEXT_WINDOW)
|
|
543
|
+
reserve_tokens: Tokens to reserve for prompt and response (default 50K)
|
|
544
|
+
include_line_numbers: Whether to add line numbers to file content
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
str: All file contents formatted for AI consumption
|
|
548
|
+
"""
|
|
549
|
+
if max_tokens is None:
|
|
550
|
+
max_tokens = DEFAULT_CONTEXT_WINDOW
|
|
551
|
+
|
|
552
|
+
logger.debug(f"[FILES] read_files called with {len(file_paths)} paths")
|
|
553
|
+
logger.debug(
|
|
554
|
+
f"[FILES] Token budget: max={max_tokens:,}, reserve={reserve_tokens:,}, available={max_tokens - reserve_tokens:,}"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
content_parts = []
|
|
558
|
+
total_tokens = 0
|
|
559
|
+
available_tokens = max_tokens - reserve_tokens
|
|
560
|
+
|
|
561
|
+
files_skipped = []
|
|
562
|
+
|
|
563
|
+
# Priority 1: Handle direct code if provided
|
|
564
|
+
# Direct code is prioritized because it's explicitly provided by the user
|
|
565
|
+
if code:
|
|
566
|
+
formatted_code = f"\n--- BEGIN DIRECT CODE ---\n{code}\n--- END DIRECT CODE ---\n"
|
|
567
|
+
code_tokens = estimate_tokens(formatted_code)
|
|
568
|
+
|
|
569
|
+
if code_tokens <= available_tokens:
|
|
570
|
+
content_parts.append(formatted_code)
|
|
571
|
+
total_tokens += code_tokens
|
|
572
|
+
available_tokens -= code_tokens
|
|
573
|
+
|
|
574
|
+
# Priority 2: Process file paths
|
|
575
|
+
if file_paths:
|
|
576
|
+
# Expand directories to get all individual files
|
|
577
|
+
logger.debug(f"[FILES] Expanding {len(file_paths)} file paths")
|
|
578
|
+
all_files = expand_paths(file_paths)
|
|
579
|
+
logger.debug(f"[FILES] After expansion: {len(all_files)} individual files")
|
|
580
|
+
|
|
581
|
+
if not all_files and file_paths:
|
|
582
|
+
# No files found but paths were provided
|
|
583
|
+
logger.debug("[FILES] No files found from provided paths")
|
|
584
|
+
content_parts.append(f"\n--- NO FILES FOUND ---\nProvided paths: {', '.join(file_paths)}\n--- END ---\n")
|
|
585
|
+
else:
|
|
586
|
+
# Read files sequentially until token limit is reached
|
|
587
|
+
logger.debug(f"[FILES] Reading {len(all_files)} files with token budget {available_tokens:,}")
|
|
588
|
+
for i, file_path in enumerate(all_files):
|
|
589
|
+
if total_tokens >= available_tokens:
|
|
590
|
+
logger.debug(f"[FILES] Token budget exhausted, skipping remaining {len(all_files) - i} files")
|
|
591
|
+
files_skipped.extend(all_files[i:])
|
|
592
|
+
break
|
|
593
|
+
|
|
594
|
+
file_content, file_tokens = read_file_content(file_path, include_line_numbers=include_line_numbers)
|
|
595
|
+
logger.debug(f"[FILES] File {file_path}: {file_tokens:,} tokens")
|
|
596
|
+
|
|
597
|
+
# Check if adding this file would exceed limit
|
|
598
|
+
if total_tokens + file_tokens <= available_tokens:
|
|
599
|
+
content_parts.append(file_content)
|
|
600
|
+
total_tokens += file_tokens
|
|
601
|
+
logger.debug(f"[FILES] Added file {file_path}, total tokens: {total_tokens:,}")
|
|
602
|
+
else:
|
|
603
|
+
# File too large for remaining budget
|
|
604
|
+
logger.debug(
|
|
605
|
+
f"[FILES] File {file_path} too large for remaining budget ({file_tokens:,} tokens, {available_tokens - total_tokens:,} remaining)"
|
|
606
|
+
)
|
|
607
|
+
files_skipped.append(file_path)
|
|
608
|
+
|
|
609
|
+
# Add informative note about skipped files to help users understand
|
|
610
|
+
# what was omitted and why
|
|
611
|
+
if files_skipped:
|
|
612
|
+
logger.debug(f"[FILES] {len(files_skipped)} files skipped due to token limits")
|
|
613
|
+
skip_note = "\n\n--- SKIPPED FILES (TOKEN LIMIT) ---\n"
|
|
614
|
+
skip_note += f"Total skipped: {len(files_skipped)}\n"
|
|
615
|
+
# Show first 10 skipped files as examples
|
|
616
|
+
for _i, file_path in enumerate(files_skipped[:10]):
|
|
617
|
+
skip_note += f" - {file_path}\n"
|
|
618
|
+
if len(files_skipped) > 10:
|
|
619
|
+
skip_note += f" ... and {len(files_skipped) - 10} more\n"
|
|
620
|
+
skip_note += "--- END SKIPPED FILES ---\n"
|
|
621
|
+
content_parts.append(skip_note)
|
|
622
|
+
|
|
623
|
+
result = "\n\n".join(content_parts) if content_parts else ""
|
|
624
|
+
logger.debug(f"[FILES] read_files complete: {len(result)} chars, {total_tokens:,} tokens used")
|
|
625
|
+
return result
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def estimate_file_tokens(file_path: str) -> int:
|
|
629
|
+
"""
|
|
630
|
+
Estimate tokens for a file using file-type aware ratios.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
file_path: Path to the file
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Estimated token count for the file
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
640
|
+
return 0
|
|
641
|
+
|
|
642
|
+
file_size = os.path.getsize(file_path)
|
|
643
|
+
|
|
644
|
+
# Get the appropriate ratio for this file type
|
|
645
|
+
from .file_types import get_token_estimation_ratio
|
|
646
|
+
|
|
647
|
+
ratio = get_token_estimation_ratio(file_path)
|
|
648
|
+
|
|
649
|
+
return int(file_size / ratio)
|
|
650
|
+
except Exception:
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def check_files_size_limit(files: list[str], max_tokens: int, threshold_percent: float = 1.0) -> tuple[bool, int, int]:
|
|
655
|
+
"""
|
|
656
|
+
Check if a list of files would exceed token limits.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
files: List of file paths to check
|
|
660
|
+
max_tokens: Maximum allowed tokens
|
|
661
|
+
threshold_percent: Percentage of max_tokens to use as threshold (0.0-1.0)
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
Tuple of (within_limit, total_estimated_tokens, file_count)
|
|
665
|
+
"""
|
|
666
|
+
if not files:
|
|
667
|
+
return True, 0, 0
|
|
668
|
+
|
|
669
|
+
total_estimated_tokens = 0
|
|
670
|
+
file_count = 0
|
|
671
|
+
threshold = int(max_tokens * threshold_percent)
|
|
672
|
+
|
|
673
|
+
for file_path in files:
|
|
674
|
+
try:
|
|
675
|
+
estimated_tokens = estimate_file_tokens(file_path)
|
|
676
|
+
total_estimated_tokens += estimated_tokens
|
|
677
|
+
if estimated_tokens > 0: # Only count accessible files
|
|
678
|
+
file_count += 1
|
|
679
|
+
except Exception:
|
|
680
|
+
# Skip files that can't be accessed for size check
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
within_limit = total_estimated_tokens <= threshold
|
|
684
|
+
return within_limit, total_estimated_tokens, file_count
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def read_json_file(file_path: str) -> Optional[dict]:
|
|
688
|
+
"""
|
|
689
|
+
Read and parse a JSON file with proper error handling.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
file_path: Path to the JSON file
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Parsed JSON data as dict, or None if file doesn't exist or invalid
|
|
696
|
+
"""
|
|
697
|
+
try:
|
|
698
|
+
if not os.path.exists(file_path):
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
with open(file_path, encoding="utf-8") as f:
|
|
702
|
+
return json.load(f)
|
|
703
|
+
except (json.JSONDecodeError, OSError):
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def write_json_file(file_path: str, data: dict, indent: int = 2) -> bool:
|
|
708
|
+
"""
|
|
709
|
+
Write data to a JSON file with proper formatting.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
file_path: Path to write the JSON file
|
|
713
|
+
data: Dictionary data to serialize
|
|
714
|
+
indent: JSON indentation level
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
True if successful, False otherwise
|
|
718
|
+
"""
|
|
719
|
+
try:
|
|
720
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
721
|
+
|
|
722
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
723
|
+
json.dump(data, f, indent=indent, ensure_ascii=False)
|
|
724
|
+
return True
|
|
725
|
+
except (OSError, TypeError):
|
|
726
|
+
return False
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def get_file_size(file_path: str) -> int:
|
|
730
|
+
"""
|
|
731
|
+
Get file size in bytes with proper error handling.
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
file_path: Path to the file
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
File size in bytes, or 0 if file doesn't exist or error
|
|
738
|
+
"""
|
|
739
|
+
try:
|
|
740
|
+
if os.path.exists(file_path) and os.path.isfile(file_path):
|
|
741
|
+
return os.path.getsize(file_path)
|
|
742
|
+
return 0
|
|
743
|
+
except OSError:
|
|
744
|
+
return 0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def ensure_directory_exists(file_path: str) -> bool:
|
|
748
|
+
"""
|
|
749
|
+
Ensure the parent directory of a file path exists.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
file_path: Path to file (directory will be created for parent)
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
True if directory exists or was created, False on error
|
|
756
|
+
"""
|
|
757
|
+
try:
|
|
758
|
+
directory = os.path.dirname(file_path)
|
|
759
|
+
if directory:
|
|
760
|
+
os.makedirs(directory, exist_ok=True)
|
|
761
|
+
return True
|
|
762
|
+
except OSError:
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def is_text_file(file_path: str) -> bool:
|
|
767
|
+
"""
|
|
768
|
+
Check if a file is likely a text file based on extension and content.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
file_path: Path to the file
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
True if file appears to be text, False otherwise
|
|
775
|
+
"""
|
|
776
|
+
from .file_types import is_text_file as check_text_type
|
|
777
|
+
|
|
778
|
+
return check_text_type(file_path)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def read_file_safely(file_path: str, max_size: int = 10 * 1024 * 1024) -> Optional[str]:
|
|
782
|
+
"""
|
|
783
|
+
Read a file with size limits and encoding handling.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
file_path: Path to the file
|
|
787
|
+
max_size: Maximum file size in bytes (default 10MB)
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
File content as string, or None if file too large or unreadable
|
|
791
|
+
"""
|
|
792
|
+
try:
|
|
793
|
+
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
file_size = os.path.getsize(file_path)
|
|
797
|
+
if file_size > max_size:
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
with open(file_path, encoding="utf-8", errors="ignore") as f:
|
|
801
|
+
return f.read()
|
|
802
|
+
except OSError:
|
|
803
|
+
return None
|