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,235 @@
1
+ """File collector module."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Iterable, List, Optional, Set, Tuple
8
+
9
+ from codecollector.config import CollectorConfig
10
+ from codecollector.gitignore import GitignoreMatcher, list_git_visible_files
11
+
12
+ # Own outputs should never be re-collected (prevents runaway growth).
13
+ _OUTPUT_ARTIFACT_RE = re.compile(
14
+ r"^code_collection(?:_\d+)?(?:\.part\d+)?\.(md|txt)$"
15
+ r"|^code_collection(?:_\d+)?\.manifest\.json$"
16
+ )
17
+
18
+
19
+ class FileCollector:
20
+ """Collects code files from directories."""
21
+
22
+ def __init__(self, config: CollectorConfig):
23
+ self.config = config
24
+ self.collected_files: List[Path] = []
25
+ self.skipped_files: List[Tuple[Path, str]] = []
26
+ self._gitignore: Optional[GitignoreMatcher] = None
27
+ self._git_files: Optional[Set[Path]] = None
28
+
29
+ if config.respect_gitignore:
30
+ self._git_files = list_git_visible_files(config.root_path)
31
+ if self._git_files is None:
32
+ # Fallback when git is unavailable
33
+ self._gitignore = GitignoreMatcher(config.root_path)
34
+ self._gitignore.load_all()
35
+
36
+ def _is_output_artifact(self, file_path: Path) -> bool:
37
+ name = file_path.name
38
+ if _OUTPUT_ARTIFACT_RE.match(name):
39
+ return True
40
+
41
+ output_name = Path(self.config.output_file).name
42
+ if name == output_name:
43
+ return True
44
+
45
+ stem = Path(self.config.output_file).stem
46
+ suffix = Path(self.config.output_file).suffix
47
+ # matches output_1.md / output.part2.md / output_1.part2.md
48
+ custom_re = re.compile(
49
+ rf"^{re.escape(stem)}(?:_\d+)?(?:\.part\d+)?{re.escape(suffix)}$"
50
+ rf"|^{re.escape(stem)}(?:_\d+)?\.manifest\.json$"
51
+ )
52
+ return bool(custom_re.match(name))
53
+
54
+ def should_collect_file(self, file_path: Path, *, check_git: bool = True) -> bool:
55
+ """Determine if a file should be collected."""
56
+ if not file_path.is_file():
57
+ return False
58
+
59
+ if file_path.is_symlink():
60
+ self.skipped_files.append((file_path, "symlink"))
61
+ return False
62
+
63
+ if self._is_output_artifact(file_path):
64
+ self.skipped_files.append((file_path, "collector output"))
65
+ return False
66
+
67
+ if check_git:
68
+ if self._git_files is not None:
69
+ if file_path.resolve() not in self._git_files:
70
+ self.skipped_files.append((file_path, "gitignored"))
71
+ return False
72
+ elif self._gitignore and self._gitignore.is_ignored(file_path):
73
+ self.skipped_files.append((file_path, "gitignored"))
74
+ return False
75
+
76
+ try:
77
+ size_mb = file_path.stat().st_size / (1024 * 1024)
78
+ if size_mb > self.config.max_file_size_mb:
79
+ self.skipped_files.append(
80
+ (file_path, f"too large ({size_mb:.1f}MB > {self.config.max_file_size_mb}MB)")
81
+ )
82
+ return False
83
+ except OSError as e:
84
+ self.skipped_files.append((file_path, f"access error ({e})"))
85
+ return False
86
+
87
+ file_name = file_path.name
88
+ rel_posix = self._relative_posix(file_path)
89
+
90
+ if self.config.exclude_patterns and self._matches_any(
91
+ rel_posix, self.config.exclude_patterns
92
+ ):
93
+ self.skipped_files.append((file_path, "excluded by pattern"))
94
+ return False
95
+
96
+ if self.config.include_patterns:
97
+ # --include overrides the extension whitelist (repomix-style).
98
+ if self._matches_any(rel_posix, self.config.include_patterns):
99
+ return self._is_text_file(file_path)
100
+ return False
101
+
102
+ if file_name in self.config.special_files:
103
+ return self._is_text_file(file_path)
104
+
105
+ suffix = file_path.suffix.lower()
106
+ if suffix in self.config.include_extensions:
107
+ return self._is_text_file(file_path)
108
+
109
+ if file_name.startswith("."):
110
+ for ext in self.config.include_extensions:
111
+ if ext.startswith(".") and file_name.endswith(ext.lstrip(".")):
112
+ return self._is_text_file(file_path)
113
+
114
+ return False
115
+
116
+ def _relative_posix(self, file_path: Path) -> str:
117
+ try:
118
+ return file_path.relative_to(self.config.root_path).as_posix()
119
+ except ValueError:
120
+ return file_path.name
121
+
122
+ @staticmethod
123
+ def _matches_any(rel_posix: str, patterns: Iterable[str]) -> bool:
124
+ name = Path(rel_posix).name
125
+ for pattern in patterns:
126
+ if pattern.endswith("/"):
127
+ base = pattern.rstrip("/")
128
+ if rel_posix.startswith(base + "/") or f"/{base}/" in f"/{rel_posix}":
129
+ return True
130
+ continue
131
+ if fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(name, pattern):
132
+ return True
133
+ return False
134
+
135
+ def _is_text_file(self, file_path: Path) -> bool:
136
+ """Check if a file is a readable text file."""
137
+ try:
138
+ with open(file_path, encoding="utf-8") as f:
139
+ chunk = f.read(1024)
140
+ if "\0" in chunk:
141
+ self.skipped_files.append((file_path, "binary file"))
142
+ return False
143
+ return True
144
+ except UnicodeDecodeError:
145
+ self.skipped_files.append((file_path, "encoding error"))
146
+ return False
147
+ except OSError as e:
148
+ self.skipped_files.append((file_path, f"read error ({e})"))
149
+ return False
150
+
151
+ def _path_in_excluded_dir(self, file_path: Path) -> bool:
152
+ try:
153
+ parts = file_path.resolve().relative_to(self.config.root_path.resolve()).parts
154
+ except ValueError:
155
+ parts = file_path.parts
156
+ return any(part in self.config.all_exclude_dirs for part in parts[:-1])
157
+
158
+ def _should_exclude_dir(self, dir_path: Path) -> bool:
159
+ """Check if a directory should be excluded."""
160
+ for part in dir_path.parts:
161
+ if part in self.config.all_exclude_dirs:
162
+ return True
163
+
164
+ if self._git_files is None and self._gitignore:
165
+ if self._gitignore.is_ignored(dir_path, is_dir=True):
166
+ return True
167
+
168
+ dir_name = dir_path.name
169
+ if dir_name.startswith(".") and dir_name != ".git":
170
+ if dir_name not in {".github", ".vscode", ".idea"}:
171
+ return True
172
+
173
+ return False
174
+
175
+ def collect_files(self) -> List[Path]:
176
+ """Collect all matching code files."""
177
+ self.collected_files.clear()
178
+ self.skipped_files.clear()
179
+
180
+ root = self.config.root_path
181
+
182
+ if not root.exists():
183
+ raise FileNotFoundError(f"Directory not found: {root}")
184
+
185
+ if not root.is_dir():
186
+ raise NotADirectoryError(f"Not a directory: {root}")
187
+
188
+ # Filter mode with git: start from git-visible files (stricter, correct).
189
+ if self.config.respect_gitignore and self._git_files is not None:
190
+ self._collect_from_git_files()
191
+ elif self.config.recursive:
192
+ self._collect_by_walk()
193
+ else:
194
+ for item in root.iterdir():
195
+ if item.is_file() and self.should_collect_file(item):
196
+ self.collected_files.append(item)
197
+
198
+ self.collected_files.sort()
199
+ return self.collected_files
200
+
201
+ def _collect_from_git_files(self) -> None:
202
+ assert self._git_files is not None
203
+ root = self.config.root_path.resolve()
204
+
205
+ for file_path in sorted(self._git_files):
206
+ try:
207
+ file_path.relative_to(root)
208
+ except ValueError:
209
+ continue
210
+
211
+ if not self.config.recursive:
212
+ if file_path.parent.resolve() != root:
213
+ continue
214
+
215
+ if self._path_in_excluded_dir(file_path):
216
+ self.skipped_files.append((file_path, "excluded directory"))
217
+ continue
218
+
219
+ # Already known to be git-visible; only apply local filters.
220
+ if self.should_collect_file(file_path, check_git=False):
221
+ self.collected_files.append(file_path)
222
+
223
+ def _collect_by_walk(self) -> None:
224
+ root = self.config.root_path
225
+ for dirpath, dirnames, filenames in os.walk(root):
226
+ current_dir = Path(dirpath)
227
+ dirnames[:] = [
228
+ d for d in dirnames
229
+ if not self._should_exclude_dir(current_dir / d)
230
+ ]
231
+
232
+ for filename in filenames:
233
+ file_path = current_dir / filename
234
+ if self.should_collect_file(file_path):
235
+ self.collected_files.append(file_path)
@@ -0,0 +1,151 @@
1
+ """Configuration management for CodeCollector."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field, fields
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Set
7
+
8
+ from codecollector.gitignore import is_inside_git_work_tree
9
+
10
+ CONFIG_DIR = Path.home() / ".config" / "codecollector"
11
+ CONFIG_FILE = CONFIG_DIR / "config.json"
12
+
13
+
14
+ def ensure_config_dir() -> None:
15
+ """Ensure configuration directory exists."""
16
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
17
+
18
+
19
+ _SET_FIELDS = {"exclude_dirs", "include_extensions", "special_files", "extra_exclude_dirs"}
20
+
21
+
22
+ @dataclass
23
+ class CollectorConfig:
24
+ """Configuration for the code collector."""
25
+
26
+ root_path: Path
27
+ recursive: bool = True
28
+ output_file: str = "code_collection.md"
29
+ output_dir: Optional[Path] = None
30
+
31
+ exclude_dirs: Set[str] = field(default_factory=lambda: {
32
+ "__pycache__", ".git", ".svn", ".hg",
33
+ "node_modules", "venv", ".venv", "env", ".env",
34
+ ".idea", ".vscode", "build", "dist", "target",
35
+ ".eggs", "*.egg-info", ".tox", ".mypy_cache",
36
+ ".pytest_cache", "__pypackages__", ".next",
37
+ ".nuxt", ".output", "coverage", ".coverage",
38
+ "tmp", "temp", "logs",
39
+ })
40
+
41
+ extra_exclude_dirs: Set[str] = field(default_factory=set)
42
+
43
+ include_extensions: Set[str] = field(default_factory=lambda: {
44
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte",
45
+ ".html", ".css", ".scss", ".less",
46
+ ".java", ".kt", ".go", ".rs", ".rb", ".php", ".swift",
47
+ ".c", ".cpp", ".h", ".hpp",
48
+ ".sh", ".bash", ".zsh",
49
+ ".yml", ".yaml", ".json", ".xml", ".toml",
50
+ ".cfg", ".ini", ".conf", ".env",
51
+ ".md", ".txt", ".rst",
52
+ ".sql", ".graphql",
53
+ "Dockerfile", ".dockerignore",
54
+ "Makefile", ".gitignore",
55
+ })
56
+
57
+ special_files: Set[str] = field(default_factory=lambda: {
58
+ "Dockerfile", "Makefile", "Vagrantfile",
59
+ ".gitignore", ".dockerignore", ".env",
60
+ ".editorconfig", ".prettierrc",
61
+ })
62
+
63
+ max_file_size_mb: float = 5.0
64
+ max_output_size_mb: float = 2.0
65
+ respect_gitignore: bool = False
66
+ auto_increment_output: bool = True
67
+ write_manifest: bool = True
68
+ show_progress: bool = True
69
+ show_skipped: bool = True
70
+
71
+ include_patterns: List[str] = field(default_factory=list)
72
+ exclude_patterns: List[str] = field(default_factory=list)
73
+
74
+ @property
75
+ def all_exclude_dirs(self) -> Set[str]:
76
+ return self.exclude_dirs | self.extra_exclude_dirs
77
+
78
+ @classmethod
79
+ def _normalize_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
80
+ normalized = {}
81
+ valid_names = {f.name for f in fields(cls)}
82
+
83
+ for key, value in data.items():
84
+ if key not in valid_names:
85
+ continue
86
+ if key in _SET_FIELDS and isinstance(value, list):
87
+ normalized[key] = set(value)
88
+ elif key in ("root_path", "output_dir") and value is not None:
89
+ normalized[key] = Path(value)
90
+ else:
91
+ normalized[key] = value
92
+
93
+ return normalized
94
+
95
+ @classmethod
96
+ def load_from_file(cls, filepath: str) -> Dict[str, Any]:
97
+ """Load configuration dict from a JSON file."""
98
+ with open(filepath, encoding="utf-8") as f:
99
+ data = json.load(f)
100
+ return cls._normalize_dict(data)
101
+
102
+ @classmethod
103
+ def from_sources(
104
+ cls,
105
+ root_path: Path,
106
+ config_file: Optional[str] = None,
107
+ overrides: Optional[Dict[str, Any]] = None,
108
+ ) -> "CollectorConfig":
109
+ """Build config by merging global config, file config, and CLI overrides.
110
+
111
+ When no source specifies `respect_gitignore`, it defaults to on inside
112
+ git work trees and off elsewhere.
113
+ """
114
+ merged: Dict[str, Any] = {}
115
+
116
+ if CONFIG_FILE.exists():
117
+ merged.update(cls.load_from_file(str(CONFIG_FILE)))
118
+
119
+ if config_file:
120
+ merged.update(cls.load_from_file(config_file))
121
+
122
+ merged["root_path"] = root_path
123
+
124
+ if overrides:
125
+ for key, value in overrides.items():
126
+ if value is not None:
127
+ if key in _SET_FIELDS and isinstance(value, list):
128
+ merged[key] = set(value)
129
+ else:
130
+ merged[key] = value
131
+
132
+ if "respect_gitignore" not in merged:
133
+ merged["respect_gitignore"] = is_inside_git_work_tree(root_path)
134
+
135
+ return cls(**cls._normalize_dict(merged))
136
+
137
+ def save_to_file(self, filepath: str) -> None:
138
+ """Save configuration to a JSON file."""
139
+ config_dict = {
140
+ "exclude_dirs": sorted(self.exclude_dirs),
141
+ "extra_exclude_dirs": sorted(self.extra_exclude_dirs),
142
+ "include_extensions": sorted(self.include_extensions),
143
+ "max_file_size_mb": self.max_file_size_mb,
144
+ "max_output_size_mb": self.max_output_size_mb,
145
+ "auto_increment_output": self.auto_increment_output,
146
+ "write_manifest": self.write_manifest,
147
+ "include_patterns": sorted(self.include_patterns),
148
+ "exclude_patterns": sorted(self.exclude_patterns),
149
+ }
150
+ with open(filepath, "w", encoding="utf-8") as f:
151
+ json.dump(config_dict, f, indent=2)
@@ -0,0 +1,197 @@
1
+ """Output formatter module."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import List, Tuple
7
+
8
+ from codecollector.config import CollectorConfig
9
+ from codecollector.tokens import count_tokens, method_label
10
+
11
+ _FENCE_RUN_RE = re.compile(r"`{3,}")
12
+ _TOP_TOKEN_FILES = 10
13
+
14
+
15
+ class OutputFormatter:
16
+ """Formats collected code files into LLM-ready Markdown."""
17
+
18
+ @staticmethod
19
+ def fence_marker(content: str) -> str:
20
+ """Return a backtick fence long enough to safely wrap the content."""
21
+ longest = max(
22
+ (len(m.group(0)) for m in _FENCE_RUN_RE.finditer(content)),
23
+ default=0,
24
+ )
25
+ return "`" * max(3, longest + 1)
26
+
27
+ @staticmethod
28
+ def format_file_content(file_path: Path, relative_to: Path) -> str:
29
+ """Format a single file as detailed Markdown."""
30
+ relative_path = file_path.relative_to(relative_to)
31
+ lang = file_path.suffix.lstrip(".") or "text"
32
+
33
+ try:
34
+ with open(file_path, encoding="utf-8") as f:
35
+ content = f.read()
36
+
37
+ line_count = content.count("\n") + 1
38
+ file_size = file_path.stat().st_size
39
+ tokens = count_tokens(content)
40
+ fence = OutputFormatter.fence_marker(content)
41
+
42
+ return (
43
+ f"### `{relative_path}`\n\n"
44
+ f"**Lines:** {line_count} | **Size:** {file_size:,} bytes "
45
+ f"| **Tokens:** {tokens:,}\n\n"
46
+ f"{fence}{lang}\n"
47
+ f"{content}\n"
48
+ f"{fence}\n\n"
49
+ )
50
+ except Exception as e:
51
+ return f"### `{relative_path}`\n\n**Read Error:** {e}\n\n"
52
+
53
+ @staticmethod
54
+ def format_summary(
55
+ collected_files: List[Path],
56
+ skipped_files: List[Tuple[Path, str]],
57
+ config: CollectorConfig,
58
+ ) -> str:
59
+ """Format the collection summary as Markdown."""
60
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
61
+
62
+ total_lines = 0
63
+ total_size = 0
64
+ total_tokens = 0
65
+ file_tokens: List[Tuple[Path, int]] = []
66
+ for fp in collected_files:
67
+ try:
68
+ with open(fp, encoding="utf-8") as f:
69
+ content = f.read()
70
+ total_lines += content.count("\n") + 1
71
+ total_size += fp.stat().st_size
72
+ tokens = count_tokens(content)
73
+ total_tokens += tokens
74
+ file_tokens.append((fp, tokens))
75
+ except Exception:
76
+ file_tokens.append((fp, 0))
77
+
78
+ summary = [
79
+ "# CodeCollector - Collection Summary",
80
+ "",
81
+ f"**Generated:** {now} ",
82
+ f"**Root:** `{config.root_path.absolute()}` ",
83
+ f"**Mode:** {'Recursive' if config.recursive else 'Non-recursive'} ",
84
+ ]
85
+
86
+ if config.respect_gitignore:
87
+ summary.append("**Filter:** `.gitignore` enabled ")
88
+
89
+ summary.extend([
90
+ "",
91
+ f"**Files collected:** {len(collected_files)} ",
92
+ f"**Total lines:** {total_lines:,} ",
93
+ f"**Total size:** {total_size / 1024:.1f} KB ",
94
+ f"**Total tokens:** {total_tokens:,} *({method_label()})* ",
95
+ ])
96
+
97
+ if collected_files:
98
+ summary.extend([
99
+ "",
100
+ "## Top Files by Tokens",
101
+ "",
102
+ "| File | Tokens |",
103
+ "| --- | ---: |",
104
+ ])
105
+ top_files = sorted(file_tokens, key=lambda x: x[1], reverse=True)
106
+ for fp, tokens in top_files[:_TOP_TOKEN_FILES]:
107
+ try:
108
+ rel_path = fp.relative_to(config.root_path).as_posix()
109
+ except ValueError:
110
+ rel_path = str(fp)
111
+ summary.append(f"| `{rel_path}` | {tokens:,} |")
112
+
113
+ if skipped_files:
114
+ summary.append("")
115
+ summary.append(f"**Files skipped:** {len(skipped_files)}")
116
+ summary.append("")
117
+ for fp, reason in skipped_files[:5]:
118
+ try:
119
+ rel_path = fp.relative_to(config.root_path)
120
+ summary.append(f"- `{rel_path}`: {reason}")
121
+ except ValueError:
122
+ summary.append(f"- `{fp}`: {reason}")
123
+ if len(skipped_files) > 5:
124
+ summary.append(f"- ... and {len(skipped_files) - 5} more")
125
+
126
+ summary.extend([
127
+ "",
128
+ "## Directory Tree",
129
+ "",
130
+ "```",
131
+ *OutputFormatter._build_directory_tree(collected_files, config.root_path),
132
+ "```",
133
+ "",
134
+ "## File List",
135
+ "",
136
+ ])
137
+
138
+ files_by_ext: dict = {}
139
+ for fp in collected_files:
140
+ ext = fp.suffix or "no_ext"
141
+ files_by_ext.setdefault(ext, [])
142
+ try:
143
+ files_by_ext[ext].append(fp.relative_to(config.root_path))
144
+ except ValueError:
145
+ files_by_ext[ext].append(fp)
146
+
147
+ for ext, files in sorted(files_by_ext.items()):
148
+ summary.append(f"**{ext}** ({len(files)} files)")
149
+ for f in files[:5]:
150
+ summary.append(f"- `{f}`")
151
+ if len(files) > 5:
152
+ summary.append(f"- ... and {len(files) - 5} more")
153
+ summary.append("")
154
+
155
+ summary.extend([
156
+ "---",
157
+ "",
158
+ "# Collected Code Content",
159
+ "",
160
+ ])
161
+
162
+ return "\n".join(summary)
163
+
164
+ @staticmethod
165
+ def _build_directory_tree(collected_files: List[Path], root: Path) -> List[str]:
166
+ """Build a simple directory tree from collected files."""
167
+ tree: dict = {}
168
+ for fp in collected_files:
169
+ try:
170
+ rel = fp.relative_to(root)
171
+ except ValueError:
172
+ rel = fp
173
+ parts = rel.parts
174
+ node = tree
175
+ for part in parts[:-1]:
176
+ node = node.setdefault(part, {})
177
+ if parts:
178
+ node.setdefault(parts[-1], None)
179
+
180
+ lines: List[str] = []
181
+
182
+ def walk(node: dict, prefix: str = "") -> None:
183
+ items = sorted(node.items(), key=lambda x: (x[1] is None, x[0]))
184
+ for i, (name, children) in enumerate(items):
185
+ is_last = i == len(items) - 1
186
+ connector = "└── " if is_last else "├── "
187
+ lines.append(f"{prefix}{connector}{name}")
188
+ if isinstance(children, dict) and children:
189
+ extension = " " if is_last else "│ "
190
+ walk(children, prefix + extension)
191
+
192
+ if tree:
193
+ walk(tree)
194
+ else:
195
+ lines.append("(empty)")
196
+
197
+ return lines