ripperdoc 0.2.6__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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Git utilities for Ripperdoc."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
import fnmatch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_git_repository(path: Path) -> bool:
|
|
10
|
+
"""Check if a directory is a git repository."""
|
|
11
|
+
try:
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
14
|
+
cwd=path,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=5,
|
|
18
|
+
)
|
|
19
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
20
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_git_root(path: Path) -> Optional[Path]:
|
|
25
|
+
"""Get the git root directory for a given path."""
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
29
|
+
cwd=path,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
timeout=5,
|
|
33
|
+
)
|
|
34
|
+
if result.returncode == 0:
|
|
35
|
+
return Path(result.stdout.strip())
|
|
36
|
+
return None
|
|
37
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_gitignore_patterns(path: Path) -> List[str]:
|
|
42
|
+
"""Read .gitignore patterns from a directory and its parent directories."""
|
|
43
|
+
patterns: List[str] = []
|
|
44
|
+
current = path
|
|
45
|
+
|
|
46
|
+
# Read .gitignore from current directory up to git root
|
|
47
|
+
git_root = get_git_root(path)
|
|
48
|
+
|
|
49
|
+
while current and (git_root is None or current.is_relative_to(git_root)):
|
|
50
|
+
gitignore_file = current / ".gitignore"
|
|
51
|
+
if gitignore_file.exists():
|
|
52
|
+
try:
|
|
53
|
+
with open(gitignore_file, "r", encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if line and not line.startswith("#"):
|
|
57
|
+
patterns.append(line)
|
|
58
|
+
except (IOError, UnicodeDecodeError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
# Also check for .git/info/exclude
|
|
62
|
+
git_info_exclude = current / ".git" / "info" / "exclude"
|
|
63
|
+
if git_info_exclude.exists():
|
|
64
|
+
try:
|
|
65
|
+
with open(git_info_exclude, "r", encoding="utf-8") as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if line and not line.startswith("#"):
|
|
69
|
+
patterns.append(line)
|
|
70
|
+
except (IOError, UnicodeDecodeError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
if current.parent == current: # Reached root
|
|
74
|
+
break
|
|
75
|
+
current = current.parent
|
|
76
|
+
|
|
77
|
+
# Add global gitignore patterns
|
|
78
|
+
global_gitignore = Path.home() / ".gitignore"
|
|
79
|
+
if global_gitignore.exists():
|
|
80
|
+
try:
|
|
81
|
+
with open(global_gitignore, "r", encoding="utf-8") as f:
|
|
82
|
+
for line in f:
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if line and not line.startswith("#"):
|
|
85
|
+
patterns.append(line)
|
|
86
|
+
except (IOError, UnicodeDecodeError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return patterns
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_gitignore_pattern(pattern: str, root_path: Path) -> Tuple[str, Optional[Path]]:
|
|
93
|
+
"""Parse a gitignore pattern and return (relative_pattern, root)."""
|
|
94
|
+
pattern = pattern.strip()
|
|
95
|
+
|
|
96
|
+
# Handle absolute paths
|
|
97
|
+
if pattern.startswith("/"):
|
|
98
|
+
return pattern[1:], root_path
|
|
99
|
+
|
|
100
|
+
# Handle patterns relative to home directory
|
|
101
|
+
if pattern.startswith("~/"):
|
|
102
|
+
home_pattern = pattern[2:]
|
|
103
|
+
return home_pattern, Path.home()
|
|
104
|
+
|
|
105
|
+
# Handle patterns with leading slash (relative to repository root)
|
|
106
|
+
if pattern.startswith("/"):
|
|
107
|
+
return pattern[1:], root_path
|
|
108
|
+
|
|
109
|
+
# Default: pattern is relative to the directory containing .gitignore
|
|
110
|
+
return pattern, None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_ignore_patterns_map(
|
|
114
|
+
root_path: Path,
|
|
115
|
+
user_ignore_patterns: Optional[List[str]] = None,
|
|
116
|
+
include_gitignore: bool = True,
|
|
117
|
+
) -> Dict[Optional[Path], List[str]]:
|
|
118
|
+
"""Build a map of ignore patterns by root directory."""
|
|
119
|
+
ignore_map: Dict[Optional[Path], List[str]] = {}
|
|
120
|
+
|
|
121
|
+
# Add user-provided ignore patterns
|
|
122
|
+
if user_ignore_patterns:
|
|
123
|
+
for pattern in user_ignore_patterns:
|
|
124
|
+
relative_pattern, pattern_root = parse_gitignore_pattern(pattern, root_path)
|
|
125
|
+
if pattern_root not in ignore_map:
|
|
126
|
+
ignore_map[pattern_root] = []
|
|
127
|
+
ignore_map[pattern_root].append(relative_pattern)
|
|
128
|
+
|
|
129
|
+
# Add .gitignore patterns
|
|
130
|
+
if include_gitignore and is_git_repository(root_path):
|
|
131
|
+
gitignore_patterns = read_gitignore_patterns(root_path)
|
|
132
|
+
for pattern in gitignore_patterns:
|
|
133
|
+
relative_pattern, pattern_root = parse_gitignore_pattern(pattern, root_path)
|
|
134
|
+
if pattern_root not in ignore_map:
|
|
135
|
+
ignore_map[pattern_root] = []
|
|
136
|
+
ignore_map[pattern_root].append(relative_pattern)
|
|
137
|
+
|
|
138
|
+
return ignore_map
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def should_ignore_path(
|
|
142
|
+
path: Path, root_path: Path, ignore_map: Dict[Optional[Path], List[str]]
|
|
143
|
+
) -> bool:
|
|
144
|
+
"""Check if a path should be ignored based on ignore patterns."""
|
|
145
|
+
# Check against each root in the ignore map
|
|
146
|
+
for pattern_root, patterns in ignore_map.items():
|
|
147
|
+
# Determine the actual root to use for pattern matching
|
|
148
|
+
actual_root = pattern_root if pattern_root is not None else root_path
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Get relative path from actual_root
|
|
152
|
+
rel_path = path.relative_to(actual_root).as_posix()
|
|
153
|
+
except ValueError:
|
|
154
|
+
# Path is not under this root, skip
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# For directories, also check with trailing slash
|
|
158
|
+
rel_path_dir = f"{rel_path}/" if path.is_dir() else rel_path
|
|
159
|
+
|
|
160
|
+
# Check each pattern
|
|
161
|
+
for pattern in patterns:
|
|
162
|
+
# Handle directory-specific patterns
|
|
163
|
+
if pattern.endswith("/"):
|
|
164
|
+
if not path.is_dir():
|
|
165
|
+
continue
|
|
166
|
+
pattern_without_slash = pattern[:-1]
|
|
167
|
+
if fnmatch.fnmatch(rel_path, pattern_without_slash) or fnmatch.fnmatch(
|
|
168
|
+
rel_path_dir, pattern
|
|
169
|
+
):
|
|
170
|
+
return True
|
|
171
|
+
else:
|
|
172
|
+
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path_dir, pattern):
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_git_status_files(root_path: Path) -> Tuple[List[str], List[str]]:
|
|
179
|
+
"""Get tracked and untracked files from git status."""
|
|
180
|
+
tracked: List[str] = []
|
|
181
|
+
untracked: List[str] = []
|
|
182
|
+
|
|
183
|
+
if not is_git_repository(root_path):
|
|
184
|
+
return tracked, untracked
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Get tracked files (modified, added, etc.)
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
["git", "status", "--porcelain"],
|
|
190
|
+
cwd=root_path,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
timeout=10,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if result.returncode == 0:
|
|
197
|
+
for line in result.stdout.strip().split("\n"):
|
|
198
|
+
if line:
|
|
199
|
+
status = line[:2].strip()
|
|
200
|
+
file_path = line[3:].strip()
|
|
201
|
+
|
|
202
|
+
# Remove quotes if present
|
|
203
|
+
if file_path.startswith('"') and file_path.endswith('"'):
|
|
204
|
+
file_path = file_path[1:-1]
|
|
205
|
+
|
|
206
|
+
if status == "??": # Untracked
|
|
207
|
+
untracked.append(file_path)
|
|
208
|
+
else: # Tracked (modified, added, etc.)
|
|
209
|
+
tracked.append(file_path)
|
|
210
|
+
|
|
211
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
return tracked, untracked
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_current_git_branch(root_path: Path) -> Optional[str]:
|
|
218
|
+
"""Get the current git branch name."""
|
|
219
|
+
if not is_git_repository(root_path):
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
["git", "branch", "--show-current"],
|
|
225
|
+
cwd=root_path,
|
|
226
|
+
capture_output=True,
|
|
227
|
+
text=True,
|
|
228
|
+
timeout=5,
|
|
229
|
+
)
|
|
230
|
+
if result.returncode == 0:
|
|
231
|
+
return result.stdout.strip()
|
|
232
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_git_commit_hash(root_path: Path) -> Optional[str]:
|
|
239
|
+
"""Get the current git commit hash."""
|
|
240
|
+
if not is_git_repository(root_path):
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
result = subprocess.run(
|
|
245
|
+
["git", "rev-parse", "HEAD"],
|
|
246
|
+
cwd=root_path,
|
|
247
|
+
capture_output=True,
|
|
248
|
+
text=True,
|
|
249
|
+
timeout=5,
|
|
250
|
+
)
|
|
251
|
+
if result.returncode == 0:
|
|
252
|
+
return result.stdout.strip()[:8] # Short hash
|
|
253
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def is_working_directory_clean(root_path: Path) -> bool:
|
|
260
|
+
"""Check if the working directory is clean (no uncommitted changes)."""
|
|
261
|
+
if not is_git_repository(root_path):
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
["git", "status", "--porcelain"],
|
|
267
|
+
cwd=root_path,
|
|
268
|
+
capture_output=True,
|
|
269
|
+
text=True,
|
|
270
|
+
timeout=5,
|
|
271
|
+
)
|
|
272
|
+
return result.returncode == 0 and not result.stdout.strip()
|
|
273
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
274
|
+
return True
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""JSON helper utilities for Ripperdoc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ripperdoc.utils.log import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_parse_json(json_text: Optional[str], log_error: bool = True) -> Optional[Any]:
|
|
15
|
+
"""Best-effort JSON.parse wrapper that returns None on failure."""
|
|
16
|
+
if not json_text:
|
|
17
|
+
return None
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(json_text)
|
|
20
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
21
|
+
if log_error:
|
|
22
|
+
logger.debug(
|
|
23
|
+
"[json_utils] Failed to parse JSON: %s: %s",
|
|
24
|
+
type(exc).__name__, exc,
|
|
25
|
+
extra={"length": len(json_text)},
|
|
26
|
+
)
|
|
27
|
+
return None
|
ripperdoc/utils/log.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Logging utilities for Ripperdoc."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from ripperdoc.utils.path_utils import sanitize_project_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_LOG_RECORD_FIELDS = {
|
|
15
|
+
"name",
|
|
16
|
+
"msg",
|
|
17
|
+
"args",
|
|
18
|
+
"levelname",
|
|
19
|
+
"levelno",
|
|
20
|
+
"pathname",
|
|
21
|
+
"filename",
|
|
22
|
+
"module",
|
|
23
|
+
"exc_info",
|
|
24
|
+
"exc_text",
|
|
25
|
+
"stack_info",
|
|
26
|
+
"lineno",
|
|
27
|
+
"funcName",
|
|
28
|
+
"created",
|
|
29
|
+
"msecs",
|
|
30
|
+
"relativeCreated",
|
|
31
|
+
"thread",
|
|
32
|
+
"threadName",
|
|
33
|
+
"processName",
|
|
34
|
+
"process",
|
|
35
|
+
"message",
|
|
36
|
+
"asctime",
|
|
37
|
+
"stacklevel",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StructuredFormatter(logging.Formatter):
|
|
42
|
+
"""Formatter with ISO timestamps and context."""
|
|
43
|
+
|
|
44
|
+
def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
|
|
45
|
+
timestamp = datetime.utcfromtimestamp(record.created)
|
|
46
|
+
return timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
47
|
+
|
|
48
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
49
|
+
message = super().format(record)
|
|
50
|
+
extras = {
|
|
51
|
+
key: value
|
|
52
|
+
for key, value in record.__dict__.items()
|
|
53
|
+
if key not in _LOG_RECORD_FIELDS and not key.startswith("_")
|
|
54
|
+
}
|
|
55
|
+
if extras:
|
|
56
|
+
try:
|
|
57
|
+
serialized = json.dumps(extras, sort_keys=True, ensure_ascii=True, default=str)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
serialized = str(extras)
|
|
60
|
+
return f"{message} | {serialized}"
|
|
61
|
+
return message
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RipperdocLogger:
|
|
65
|
+
"""Logger for Ripperdoc."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, name: str = "ripperdoc", log_dir: Optional[Path] = None):
|
|
68
|
+
self.logger = logging.getLogger(name)
|
|
69
|
+
level_name = os.getenv("RIPPERDOC_LOG_LEVEL", "WARNING").upper()
|
|
70
|
+
level = getattr(logging, level_name, logging.WARNING)
|
|
71
|
+
# Allow file handlers to capture debug logs while console respects the configured level.
|
|
72
|
+
self.logger.setLevel(logging.DEBUG)
|
|
73
|
+
self.logger.propagate = False
|
|
74
|
+
|
|
75
|
+
# Avoid adding duplicate handlers if an existing logger is reused.
|
|
76
|
+
if not self.logger.handlers:
|
|
77
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
78
|
+
console_handler.setLevel(level)
|
|
79
|
+
console_formatter = logging.Formatter("%(levelname)s: %(message)s")
|
|
80
|
+
console_handler.setFormatter(console_formatter)
|
|
81
|
+
self.logger.addHandler(console_handler)
|
|
82
|
+
|
|
83
|
+
self._file_handler: Optional[logging.Handler] = None
|
|
84
|
+
self._file_handler_path: Optional[Path] = None
|
|
85
|
+
|
|
86
|
+
if log_dir:
|
|
87
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
log_file = log_dir / f"ripperdoc_{datetime.now().strftime('%Y%m%d')}.log"
|
|
89
|
+
self.attach_file_handler(log_file)
|
|
90
|
+
|
|
91
|
+
def attach_file_handler(self, log_file: Path) -> Path:
|
|
92
|
+
"""Attach or replace a file handler for logging to disk."""
|
|
93
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
if self._file_handler and self._file_handler_path == log_file:
|
|
95
|
+
return log_file
|
|
96
|
+
|
|
97
|
+
if self._file_handler:
|
|
98
|
+
try:
|
|
99
|
+
self.logger.removeHandler(self._file_handler)
|
|
100
|
+
except (ValueError, RuntimeError):
|
|
101
|
+
# Swallow errors while rotating handlers; console logging should continue.
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Use UTF-8 to avoid Windows code page encoding errors when logs contain non-ASCII text.
|
|
105
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
106
|
+
file_handler.setLevel(logging.DEBUG)
|
|
107
|
+
file_formatter = StructuredFormatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
108
|
+
file_handler.setFormatter(file_formatter)
|
|
109
|
+
self.logger.addHandler(file_handler)
|
|
110
|
+
self._file_handler = file_handler
|
|
111
|
+
self._file_handler_path = log_file
|
|
112
|
+
return log_file
|
|
113
|
+
|
|
114
|
+
def debug(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
115
|
+
"""Log debug message."""
|
|
116
|
+
self.logger.debug(message, *args, **kwargs)
|
|
117
|
+
|
|
118
|
+
def info(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
119
|
+
"""Log info message."""
|
|
120
|
+
self.logger.info(message, *args, **kwargs)
|
|
121
|
+
|
|
122
|
+
def warning(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
123
|
+
"""Log warning message."""
|
|
124
|
+
self.logger.warning(message, *args, **kwargs)
|
|
125
|
+
|
|
126
|
+
def error(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
127
|
+
"""Log error message."""
|
|
128
|
+
self.logger.error(message, *args, **kwargs)
|
|
129
|
+
|
|
130
|
+
def critical(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
131
|
+
"""Log critical message."""
|
|
132
|
+
self.logger.critical(message, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
def exception(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
135
|
+
"""Log an exception with traceback."""
|
|
136
|
+
self.logger.exception(message, *args, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Global logger instance
|
|
140
|
+
_logger: Optional[RipperdocLogger] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_logger() -> RipperdocLogger:
|
|
144
|
+
"""Get the global logger instance."""
|
|
145
|
+
global _logger
|
|
146
|
+
if _logger is None:
|
|
147
|
+
_logger = RipperdocLogger()
|
|
148
|
+
return _logger
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _normalize_path_for_logs(project_path: Path) -> Path:
|
|
152
|
+
"""Return the directory for log files for a given project."""
|
|
153
|
+
safe_name = sanitize_project_path(project_path)
|
|
154
|
+
return Path.home() / ".ripperdoc" / "logs" / safe_name
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def session_log_path(project_path: Path, session_id: str, when: Optional[datetime] = None) -> Path:
|
|
158
|
+
"""Build the log file path for a project session."""
|
|
159
|
+
timestamp = (when or datetime.now()).strftime("%Y%m%d-%H%M%S")
|
|
160
|
+
return _normalize_path_for_logs(project_path) / f"{timestamp}-{session_id}.log"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def init_logger(log_dir: Optional[Path] = None) -> RipperdocLogger:
|
|
164
|
+
"""Initialize the global logger."""
|
|
165
|
+
global _logger
|
|
166
|
+
_logger = RipperdocLogger(log_dir=log_dir)
|
|
167
|
+
return _logger
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def enable_session_file_logging(project_path: Path, session_id: str) -> Path:
|
|
171
|
+
"""Ensure the global logger writes to the session-specific log file."""
|
|
172
|
+
logger = get_logger()
|
|
173
|
+
log_file = session_log_path(project_path, session_id)
|
|
174
|
+
logger.attach_file_handler(log_file)
|
|
175
|
+
logger.debug(f"[logging] File logging enabled at {log_file}")
|
|
176
|
+
return log_file
|