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.
@@ -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
@@ -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
@@ -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