onepaste 1.3.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.
- codecollector/__init__.py +10 -0
- codecollector/__main__.py +6 -0
- codecollector/cli.py +568 -0
- codecollector/collector.py +235 -0
- codecollector/config.py +151 -0
- codecollector/formatter.py +197 -0
- codecollector/gitignore.py +160 -0
- codecollector/output.py +61 -0
- codecollector/selector.py +115 -0
- codecollector/splitter.py +142 -0
- codecollector/tokens.py +56 -0
- codecollector/uninstall.py +118 -0
- onepaste-1.3.0.dist-info/METADATA +241 -0
- onepaste-1.3.0.dist-info/RECORD +17 -0
- onepaste-1.3.0.dist-info/WHEEL +4 -0
- onepaste-1.3.0.dist-info/entry_points.txt +2 -0
- onepaste-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Git-aware ignore checking for filter mode."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Set, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitignoreMatcher:
|
|
10
|
+
"""Match paths against .gitignore rules (fallback when git is unavailable)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, root: Path):
|
|
13
|
+
self.root = root.resolve()
|
|
14
|
+
self._rules: List[Tuple[str, bool, bool]] = [] # pattern, negation, dir_only
|
|
15
|
+
|
|
16
|
+
def load_all(self) -> None:
|
|
17
|
+
"""Load the root .gitignore and nested ones (skipping .git)."""
|
|
18
|
+
root_gitignore = self.root / ".gitignore"
|
|
19
|
+
if root_gitignore.is_file():
|
|
20
|
+
self._load_file(root_gitignore)
|
|
21
|
+
|
|
22
|
+
for gitignore in self.root.rglob(".gitignore"):
|
|
23
|
+
if gitignore == root_gitignore:
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
if ".git" in gitignore.relative_to(self.root).parts:
|
|
27
|
+
continue
|
|
28
|
+
except ValueError:
|
|
29
|
+
continue
|
|
30
|
+
self._load_file(gitignore)
|
|
31
|
+
|
|
32
|
+
def _load_file(self, gitignore_path: Path) -> None:
|
|
33
|
+
try:
|
|
34
|
+
rel_base = gitignore_path.parent.relative_to(self.root)
|
|
35
|
+
prefix = "" if rel_base == Path(".") else rel_base.as_posix() + "/"
|
|
36
|
+
except ValueError:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
41
|
+
except OSError:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
for raw_line in lines:
|
|
45
|
+
line = raw_line.strip()
|
|
46
|
+
if not line or line.startswith("#"):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
negation = line.startswith("!")
|
|
50
|
+
if negation:
|
|
51
|
+
line = line[1:].strip()
|
|
52
|
+
if not line:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
dir_only = line.endswith("/")
|
|
56
|
+
if dir_only:
|
|
57
|
+
line = line.rstrip("/")
|
|
58
|
+
|
|
59
|
+
if line.startswith("/"):
|
|
60
|
+
pattern = prefix + line.lstrip("/")
|
|
61
|
+
elif "/" in line:
|
|
62
|
+
pattern = prefix + line
|
|
63
|
+
else:
|
|
64
|
+
# gitignore: pattern without slash matches in any directory
|
|
65
|
+
pattern = prefix + "**/" + line if prefix else "**/" + line
|
|
66
|
+
|
|
67
|
+
self._rules.append((pattern, negation, dir_only))
|
|
68
|
+
|
|
69
|
+
def is_ignored(self, path: Path, is_dir: bool = False) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
rel = path.resolve().relative_to(self.root).as_posix()
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if not rel or rel == ".":
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
name = path.name
|
|
79
|
+
ignored = False
|
|
80
|
+
|
|
81
|
+
for pattern, negation, dir_only in self._rules:
|
|
82
|
+
if dir_only and not is_dir:
|
|
83
|
+
continue
|
|
84
|
+
if self._matches(pattern, rel, name):
|
|
85
|
+
ignored = not negation
|
|
86
|
+
|
|
87
|
+
return ignored
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _matches(pattern: str, rel_path: str, name: str) -> bool:
|
|
91
|
+
candidates = {rel_path, name}
|
|
92
|
+
|
|
93
|
+
# "**/foo" also matches top-level "foo"
|
|
94
|
+
if pattern.startswith("**/"):
|
|
95
|
+
candidates.add(pattern[3:])
|
|
96
|
+
bare = pattern[3:]
|
|
97
|
+
if fnmatch.fnmatch(name, bare) or fnmatch.fnmatch(rel_path, bare):
|
|
98
|
+
return True
|
|
99
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
100
|
+
return True
|
|
101
|
+
# match in nested path segments
|
|
102
|
+
parts = rel_path.split("/")
|
|
103
|
+
for i in range(len(parts)):
|
|
104
|
+
sub = "/".join(parts[i:])
|
|
105
|
+
if fnmatch.fnmatch(sub, bare) or fnmatch.fnmatch(parts[i], bare):
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
for target in candidates:
|
|
110
|
+
if fnmatch.fnmatch(target, pattern):
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_inside_git_work_tree(root: Path) -> bool:
|
|
117
|
+
"""Return True if root is inside a git working tree.
|
|
118
|
+
|
|
119
|
+
Fast probe used for the default-on .gitignore behaviour. Returns False
|
|
120
|
+
when git is unavailable or root is not inside a work tree.
|
|
121
|
+
"""
|
|
122
|
+
root = root.resolve()
|
|
123
|
+
try:
|
|
124
|
+
probe = subprocess.run(
|
|
125
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
check=False,
|
|
129
|
+
)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
return probe.returncode == 0 and probe.stdout.strip() == "true"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def list_git_visible_files(root: Path) -> Optional[Set[Path]]:
|
|
137
|
+
"""Return files that are tracked or untracked-but-not-ignored.
|
|
138
|
+
|
|
139
|
+
Uses `git ls-files -co --exclude-standard`. Returns None if git is
|
|
140
|
+
unavailable or root is not inside a git work tree.
|
|
141
|
+
"""
|
|
142
|
+
root = root.resolve()
|
|
143
|
+
if not is_inside_git_work_tree(root):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
["git", "-C", str(root), "ls-files", "-co", "--exclude-standard", "-z"],
|
|
148
|
+
capture_output=True,
|
|
149
|
+
check=False,
|
|
150
|
+
)
|
|
151
|
+
if result.returncode != 0:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
files: Set[Path] = set()
|
|
155
|
+
for raw in result.stdout.split(b"\0"):
|
|
156
|
+
if not raw:
|
|
157
|
+
continue
|
|
158
|
+
rel = raw.decode("utf-8", errors="replace")
|
|
159
|
+
files.add((root / rel).resolve())
|
|
160
|
+
return files
|
codecollector/output.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Output path resolution and manifest generation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def output_files_exist(output_dir: Path, base_filename: str) -> bool:
|
|
10
|
+
"""Check if a collection output (single or split) already exists."""
|
|
11
|
+
if (output_dir / base_filename).exists():
|
|
12
|
+
return True
|
|
13
|
+
|
|
14
|
+
stem = Path(base_filename).stem
|
|
15
|
+
suffix = Path(base_filename).suffix
|
|
16
|
+
return any(output_dir.glob(f"{stem}.part*{suffix}"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_unique_filename(
|
|
20
|
+
output_dir: Path,
|
|
21
|
+
base_filename: str,
|
|
22
|
+
force: bool = False,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Return a filename that won't overwrite existing collection output."""
|
|
25
|
+
if force or not output_files_exist(output_dir, base_filename):
|
|
26
|
+
return base_filename
|
|
27
|
+
|
|
28
|
+
stem = Path(base_filename).stem
|
|
29
|
+
suffix = Path(base_filename).suffix
|
|
30
|
+
counter = 1
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
candidate = f"{stem}_{counter}{suffix}"
|
|
34
|
+
if not output_files_exist(output_dir, candidate):
|
|
35
|
+
return candidate
|
|
36
|
+
counter += 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_manifest(
|
|
40
|
+
output_dir: Path,
|
|
41
|
+
base_filename: str,
|
|
42
|
+
output_paths: List[Path],
|
|
43
|
+
root_path: Path,
|
|
44
|
+
files_collected: int,
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Write a manifest JSON describing the collection output."""
|
|
47
|
+
stem = Path(base_filename).stem
|
|
48
|
+
manifest_path = output_dir / f"{stem}.manifest.json"
|
|
49
|
+
manifest = {
|
|
50
|
+
"generated": datetime.now().isoformat(timespec="seconds"),
|
|
51
|
+
"root": str(root_path.absolute()),
|
|
52
|
+
"base_filename": base_filename,
|
|
53
|
+
"total_parts": len(output_paths),
|
|
54
|
+
"files_collected": files_collected,
|
|
55
|
+
"parts": [p.name for p in output_paths],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
with open(manifest_path, "w", encoding="utf-8") as f:
|
|
59
|
+
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
|
60
|
+
|
|
61
|
+
return manifest_path
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Interactive directory selector module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.theme import Theme
|
|
12
|
+
|
|
13
|
+
custom_theme = Theme({
|
|
14
|
+
"info": "cyan",
|
|
15
|
+
"warning": "yellow",
|
|
16
|
+
"error": "red bold",
|
|
17
|
+
"success": "green bold",
|
|
18
|
+
"path": "blue",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
console = Console(theme=custom_theme)
|
|
22
|
+
|
|
23
|
+
custom_style = questionary.Style([
|
|
24
|
+
("qmark", "fg:#673ab7 bold"),
|
|
25
|
+
("question", "bold"),
|
|
26
|
+
("answer", "fg:#f44336 bold"),
|
|
27
|
+
("pointer", "fg:#673ab7 bold"),
|
|
28
|
+
("highlighted", "fg:#673ab7 bold"),
|
|
29
|
+
("selected", "fg:#cc5454"),
|
|
30
|
+
("separator", "fg:#cc5454"),
|
|
31
|
+
("disabled", "fg:#858585 italic"),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InteractiveSelector:
|
|
36
|
+
"""Interactive directory selector with keyboard navigation."""
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def select_directory(start_path: Optional[Path] = None) -> Optional[Path]:
|
|
40
|
+
"""Interactively select a directory to collect."""
|
|
41
|
+
if start_path is None:
|
|
42
|
+
start_path = Path.cwd()
|
|
43
|
+
|
|
44
|
+
current_path = start_path.resolve()
|
|
45
|
+
|
|
46
|
+
while True:
|
|
47
|
+
os.system("clear" if os.name != "nt" else "cls")
|
|
48
|
+
|
|
49
|
+
console.print()
|
|
50
|
+
console.print(Panel.fit(
|
|
51
|
+
"[bold cyan]CodeCollector[/bold cyan] [dim]- Select directory, press Enter to collect[/dim]",
|
|
52
|
+
border_style="cyan",
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
path_text = Text()
|
|
56
|
+
path_text.append("Directory: ", style="bold white")
|
|
57
|
+
path_text.append(str(current_path), style="blue")
|
|
58
|
+
console.print(path_text)
|
|
59
|
+
console.print("─" * 60)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
items = sorted(current_path.iterdir())
|
|
63
|
+
dirs = [item for item in items if item.is_dir() and not item.name.startswith(".")]
|
|
64
|
+
|
|
65
|
+
choices = []
|
|
66
|
+
|
|
67
|
+
choices.append(questionary.Separator("─" * 28 + " Collect " + "─" * 28))
|
|
68
|
+
choices.append({
|
|
69
|
+
"name": " ✅ Collect this directory",
|
|
70
|
+
"value": ".",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
choices.append(questionary.Separator("─" * 28 + " Navigate " + "─" * 28))
|
|
74
|
+
if current_path != current_path.parent:
|
|
75
|
+
choices.append({"name": " ⬆️ .. (parent directory)", "value": ".."})
|
|
76
|
+
|
|
77
|
+
for d in dirs:
|
|
78
|
+
choices.append({"name": f" 📁 {d.name}/", "value": d.name})
|
|
79
|
+
|
|
80
|
+
choices.append(questionary.Separator("─" * 60))
|
|
81
|
+
choices.append({"name": " Exit", "value": "quit"})
|
|
82
|
+
|
|
83
|
+
selected = questionary.select(
|
|
84
|
+
"Select:",
|
|
85
|
+
choices=choices,
|
|
86
|
+
style=custom_style,
|
|
87
|
+
use_indicator=True,
|
|
88
|
+
qmark="👉",
|
|
89
|
+
pointer="❯",
|
|
90
|
+
).ask()
|
|
91
|
+
|
|
92
|
+
if selected is None or selected == "quit":
|
|
93
|
+
return None
|
|
94
|
+
elif selected == "..":
|
|
95
|
+
current_path = current_path.parent
|
|
96
|
+
elif selected == ".":
|
|
97
|
+
return current_path
|
|
98
|
+
else:
|
|
99
|
+
new_path = current_path / selected
|
|
100
|
+
if new_path.is_dir():
|
|
101
|
+
current_path = new_path
|
|
102
|
+
else:
|
|
103
|
+
console.print(f"\n[error]Cannot enter: {selected}[/error]")
|
|
104
|
+
console.input("[dim]Press Enter to continue...[/dim]")
|
|
105
|
+
|
|
106
|
+
except PermissionError:
|
|
107
|
+
console.print("\n[error]Permission denied[/error]")
|
|
108
|
+
console.input("[dim]Press Enter to continue...[/dim]")
|
|
109
|
+
if current_path != current_path.parent:
|
|
110
|
+
current_path = current_path.parent
|
|
111
|
+
else:
|
|
112
|
+
return None
|
|
113
|
+
except KeyboardInterrupt:
|
|
114
|
+
console.print("\n\n[yellow]Cancelled[/yellow]")
|
|
115
|
+
return None
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Output file splitting for large collections."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from codecollector.config import CollectorConfig
|
|
7
|
+
from codecollector.output import resolve_unique_filename
|
|
8
|
+
|
|
9
|
+
HEADER_SIZE_BUFFER = 512
|
|
10
|
+
ESTIMATED_PART_HEADER = (
|
|
11
|
+
"# CodeCollector - Part 999 of 999\n\n"
|
|
12
|
+
f"- **Root:** `{'x' * 60}`\n"
|
|
13
|
+
"- **Files in this part:** 999\n"
|
|
14
|
+
+ "\n".join(f" - `{'x' * 60}`" for _ in range(10))
|
|
15
|
+
+ "\n\n---\n\n"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def byte_size(text: str) -> int:
|
|
20
|
+
return len(text.encode("utf-8"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_part_filename(base_filename: str, part: int) -> str:
|
|
24
|
+
path = Path(base_filename)
|
|
25
|
+
return f"{path.stem}.part{part}{path.suffix}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_part_header(
|
|
29
|
+
part: int,
|
|
30
|
+
total: int,
|
|
31
|
+
config: CollectorConfig,
|
|
32
|
+
files_in_part: List[Path],
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Format the header for a split output file."""
|
|
35
|
+
lines = [
|
|
36
|
+
f"# CodeCollector - Part {part} of {total}",
|
|
37
|
+
"",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if part > 1:
|
|
41
|
+
lines.append(f"- **Root:** `{config.root_path.absolute()}`")
|
|
42
|
+
lines.append(f"- **Files in this part:** {len(files_in_part)}")
|
|
43
|
+
lines.append("")
|
|
44
|
+
for fp in files_in_part:
|
|
45
|
+
try:
|
|
46
|
+
lines.append(f" - `{fp.relative_to(config.root_path)}`")
|
|
47
|
+
except ValueError:
|
|
48
|
+
lines.append(f" - `{fp}`")
|
|
49
|
+
lines.append("")
|
|
50
|
+
lines.append("---")
|
|
51
|
+
lines.append("")
|
|
52
|
+
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plan_parts(
|
|
57
|
+
chunks: List[str],
|
|
58
|
+
first_part_prefix: str,
|
|
59
|
+
max_bytes: int,
|
|
60
|
+
) -> List[List[int]]:
|
|
61
|
+
"""Plan which chunk indices belong in each output part."""
|
|
62
|
+
if max_bytes <= 0:
|
|
63
|
+
return [list(range(len(chunks)))]
|
|
64
|
+
|
|
65
|
+
parts: List[List[int]] = []
|
|
66
|
+
current: List[int] = []
|
|
67
|
+
current_size = 0
|
|
68
|
+
part_num = 1
|
|
69
|
+
header_overhead = byte_size(ESTIMATED_PART_HEADER) + HEADER_SIZE_BUFFER
|
|
70
|
+
|
|
71
|
+
for idx, chunk in enumerate(chunks):
|
|
72
|
+
chunk_size = byte_size(chunk)
|
|
73
|
+
overhead = header_overhead
|
|
74
|
+
|
|
75
|
+
if part_num == 1:
|
|
76
|
+
overhead += byte_size(first_part_prefix)
|
|
77
|
+
|
|
78
|
+
budget = max_bytes - overhead
|
|
79
|
+
|
|
80
|
+
if current and current_size + chunk_size > budget:
|
|
81
|
+
parts.append(current)
|
|
82
|
+
current = [idx]
|
|
83
|
+
current_size = chunk_size
|
|
84
|
+
part_num += 1
|
|
85
|
+
else:
|
|
86
|
+
current.append(idx)
|
|
87
|
+
current_size += chunk_size
|
|
88
|
+
|
|
89
|
+
if current:
|
|
90
|
+
parts.append(current)
|
|
91
|
+
|
|
92
|
+
return parts
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_collection_output(
|
|
96
|
+
output_dir: Path,
|
|
97
|
+
base_filename: str,
|
|
98
|
+
summary: str,
|
|
99
|
+
collected_files: List[Path],
|
|
100
|
+
file_contents: List[str],
|
|
101
|
+
config: CollectorConfig,
|
|
102
|
+
force: bool = False,
|
|
103
|
+
) -> Tuple[List[Path], str]:
|
|
104
|
+
"""Write collection output, splitting into multiple files when needed.
|
|
105
|
+
|
|
106
|
+
Returns (output_paths, resolved_base_filename).
|
|
107
|
+
"""
|
|
108
|
+
resolved_name = base_filename
|
|
109
|
+
if config.auto_increment_output:
|
|
110
|
+
resolved_name = resolve_unique_filename(output_dir, base_filename, force=force)
|
|
111
|
+
|
|
112
|
+
max_bytes = int(config.max_output_size_mb * 1024 * 1024)
|
|
113
|
+
output_path = output_dir / resolved_name
|
|
114
|
+
|
|
115
|
+
part_indices = plan_parts(file_contents, summary, max_bytes)
|
|
116
|
+
|
|
117
|
+
if len(part_indices) <= 1:
|
|
118
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
119
|
+
f.write(summary)
|
|
120
|
+
for content in file_contents:
|
|
121
|
+
f.write(content)
|
|
122
|
+
return [output_path], resolved_name
|
|
123
|
+
|
|
124
|
+
total_parts = len(part_indices)
|
|
125
|
+
output_paths: List[Path] = []
|
|
126
|
+
|
|
127
|
+
for part_num, indices in enumerate(part_indices, start=1):
|
|
128
|
+
part_filename = make_part_filename(resolved_name, part_num)
|
|
129
|
+
part_path = output_dir / part_filename
|
|
130
|
+
files_in_part = [collected_files[i] for i in indices]
|
|
131
|
+
header = format_part_header(part_num, total_parts, config, files_in_part)
|
|
132
|
+
|
|
133
|
+
with open(part_path, "w", encoding="utf-8") as f:
|
|
134
|
+
f.write(header)
|
|
135
|
+
if part_num == 1:
|
|
136
|
+
f.write(summary)
|
|
137
|
+
for i in indices:
|
|
138
|
+
f.write(file_contents[i])
|
|
139
|
+
|
|
140
|
+
output_paths.append(part_path)
|
|
141
|
+
|
|
142
|
+
return output_paths, resolved_name
|
codecollector/tokens.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Token counting with graceful degradation.
|
|
2
|
+
|
|
3
|
+
Uses tiktoken (o200k_base) when available for exact counts; falls back to a
|
|
4
|
+
characters/4 estimate when the optional dependency is missing or the encoding
|
|
5
|
+
cannot be loaded (e.g. offline first run).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tiktoken as _tiktoken
|
|
12
|
+
except ImportError: # pragma: no cover - exercised via fallback tests
|
|
13
|
+
_tiktoken = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
CHARS_PER_TOKEN_ESTIMATE = 4
|
|
16
|
+
_ENCODING_NAME = "o200k_base"
|
|
17
|
+
|
|
18
|
+
_encoding: Optional[Any] = None
|
|
19
|
+
_encoding_failed = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_encoding() -> Optional[Any]:
|
|
23
|
+
global _encoding, _encoding_failed
|
|
24
|
+
if _encoding is not None or _encoding_failed:
|
|
25
|
+
return _encoding
|
|
26
|
+
if _tiktoken is None:
|
|
27
|
+
_encoding_failed = True
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
_encoding = _tiktoken.get_encoding(_ENCODING_NAME)
|
|
31
|
+
except Exception:
|
|
32
|
+
_encoding_failed = True
|
|
33
|
+
return _encoding
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_exact() -> bool:
|
|
37
|
+
"""True when counts come from tiktoken rather than the estimator."""
|
|
38
|
+
return _get_encoding() is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def method_label() -> str:
|
|
42
|
+
"""Human-readable label describing the counting method."""
|
|
43
|
+
if is_exact():
|
|
44
|
+
return f"tiktoken {_ENCODING_NAME}"
|
|
45
|
+
return f"estimate (~{CHARS_PER_TOKEN_ESTIMATE} chars/token)"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def count_tokens(text: str) -> int:
|
|
49
|
+
"""Count tokens in text, degrading to an estimate when needed."""
|
|
50
|
+
encoding = _get_encoding()
|
|
51
|
+
if encoding is not None:
|
|
52
|
+
try:
|
|
53
|
+
return len(encoding.encode(text))
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return max(1, (len(text) + CHARS_PER_TOKEN_ESTIMATE - 1) // CHARS_PER_TOKEN_ESTIMATE)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Uninstall helpers for development and updates."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from codecollector.config import CONFIG_DIR
|
|
10
|
+
|
|
11
|
+
PACKAGE_NAME = "codecollector"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(cmd: List[str]) -> Tuple[int, str]:
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
cmd,
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
check=False,
|
|
21
|
+
)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
return 127, f"command not found: {cmd[0]}"
|
|
24
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
25
|
+
return result.returncode, output.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_command(name: str) -> bool:
|
|
29
|
+
return shutil.which(name) is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pipx_has_package() -> bool:
|
|
33
|
+
code, output = _run(["pipx", "list", "--short"])
|
|
34
|
+
if code != 0:
|
|
35
|
+
return False
|
|
36
|
+
return any(line.split()[0] == PACKAGE_NAME for line in output.splitlines() if line.strip())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pip_has_package() -> bool:
|
|
40
|
+
code, _ = _run([sys.executable, "-m", "pip", "show", PACKAGE_NAME])
|
|
41
|
+
return code == 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_install_methods() -> List[str]:
|
|
45
|
+
"""Return known install methods currently present."""
|
|
46
|
+
methods: List[str] = []
|
|
47
|
+
if _has_command("pipx") and _pipx_has_package():
|
|
48
|
+
methods.append("pipx")
|
|
49
|
+
if _pip_has_package():
|
|
50
|
+
methods.append("pip")
|
|
51
|
+
return methods
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remove_config_dir() -> Optional[Path]:
|
|
55
|
+
"""Remove the user config directory if it exists."""
|
|
56
|
+
if not CONFIG_DIR.exists():
|
|
57
|
+
return None
|
|
58
|
+
shutil.rmtree(CONFIG_DIR)
|
|
59
|
+
return CONFIG_DIR
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def uninstall_package(purge_config: bool = False) -> Tuple[bool, List[str]]:
|
|
63
|
+
"""Uninstall codecollector via pipx and/or pip.
|
|
64
|
+
|
|
65
|
+
Returns (success, messages).
|
|
66
|
+
"""
|
|
67
|
+
messages: List[str] = []
|
|
68
|
+
methods = detect_install_methods()
|
|
69
|
+
|
|
70
|
+
if not methods:
|
|
71
|
+
messages.append(
|
|
72
|
+
f"No installed `{PACKAGE_NAME}` found via pipx or pip.\n"
|
|
73
|
+
"If you run from source with PYTHONPATH, just stop using that path."
|
|
74
|
+
)
|
|
75
|
+
if purge_config:
|
|
76
|
+
removed = remove_config_dir()
|
|
77
|
+
if removed:
|
|
78
|
+
messages.append(f"Removed config: {removed}")
|
|
79
|
+
else:
|
|
80
|
+
messages.append(f"No config directory to remove: {CONFIG_DIR}")
|
|
81
|
+
return False, messages
|
|
82
|
+
|
|
83
|
+
success = True
|
|
84
|
+
|
|
85
|
+
if "pipx" in methods:
|
|
86
|
+
code, output = _run(["pipx", "uninstall", PACKAGE_NAME])
|
|
87
|
+
if code == 0:
|
|
88
|
+
messages.append(f"Uninstalled via pipx: {PACKAGE_NAME}")
|
|
89
|
+
else:
|
|
90
|
+
success = False
|
|
91
|
+
messages.append(f"pipx uninstall failed:\n{output or '(no output)'}")
|
|
92
|
+
|
|
93
|
+
if "pip" in methods:
|
|
94
|
+
code, output = _run(
|
|
95
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", PACKAGE_NAME]
|
|
96
|
+
)
|
|
97
|
+
if code == 0:
|
|
98
|
+
messages.append(f"Uninstalled via pip: {PACKAGE_NAME}")
|
|
99
|
+
else:
|
|
100
|
+
success = False
|
|
101
|
+
messages.append(f"pip uninstall failed:\n{output or '(no output)'}")
|
|
102
|
+
|
|
103
|
+
if purge_config:
|
|
104
|
+
removed = remove_config_dir()
|
|
105
|
+
if removed:
|
|
106
|
+
messages.append(f"Removed config: {removed}")
|
|
107
|
+
else:
|
|
108
|
+
messages.append(f"No config directory to remove: {CONFIG_DIR}")
|
|
109
|
+
|
|
110
|
+
if success:
|
|
111
|
+
messages.append(
|
|
112
|
+
"Reinstall from source with:\n"
|
|
113
|
+
f" pipx install -e {Path.cwd()}\n"
|
|
114
|
+
"or:\n"
|
|
115
|
+
" pip install -e ."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return success, messages
|