contextzip 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {contextzip-0.2.2 → contextzip-0.2.4}/PKG-INFO +2 -2
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/__init__.py +1 -1
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/cli.py +2 -1
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/detector.py +2 -2
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/filters.py +17 -6
- contextzip-0.2.4/contextzip/packager.py +275 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/base.py +17 -4
- contextzip-0.2.4/contextzip/rules/ruby.py +41 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/PKG-INFO +2 -2
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/pyproject.toml +2 -2
- contextzip-0.2.2/contextzip/packager.py +0 -152
- {contextzip-0.2.2 → contextzip-0.2.4}/LICENSE +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/README.md +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/clipboard.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/git.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/go.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/node.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/python.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/rust.py +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.2.2 → contextzip-0.2.4}/setup.cfg +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/akadeepesh/contextzip
|
|
8
8
|
Project-URL: Repository, https://github.com/akadeepesh/contextzip
|
|
9
9
|
Project-URL: Issues, https://github.com/akadeepesh/contextzip/issues
|
|
10
|
-
Keywords: ai,cli,developer-tools,context,zip,llm
|
|
10
|
+
Keywords: ai,cli,developer-tools,context,zip,llm,claude,chatgpt,code-packaging
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -74,7 +74,7 @@ def _modifier_options(f):
|
|
|
74
74
|
click.option(
|
|
75
75
|
"--output", "-o",
|
|
76
76
|
default=None, metavar="FILE",
|
|
77
|
-
help="Output ZIP path.
|
|
77
|
+
help="Output ZIP path. Bypasses .contextzip/ workspace — writes directly to FILE.",
|
|
78
78
|
),
|
|
79
79
|
click.option(
|
|
80
80
|
"--no-clipboard",
|
|
@@ -431,6 +431,7 @@ def _run(
|
|
|
431
431
|
project_dir=project_dir,
|
|
432
432
|
output_path=output_path,
|
|
433
433
|
console=console,
|
|
434
|
+
git_changes=git_changes,
|
|
434
435
|
)
|
|
435
436
|
except Exception as exc:
|
|
436
437
|
console.print(f"\n[red]Failed to create ZIP:[/] {exc}")
|
|
@@ -120,7 +120,7 @@ _RULES: list[_Rule] = [
|
|
|
120
120
|
# ── Ruby ─────────────────────────────────────────────────────────────
|
|
121
121
|
_Rule(
|
|
122
122
|
name="Ruby",
|
|
123
|
-
module="
|
|
123
|
+
module="ruby",
|
|
124
124
|
check=lambda p: _file_exists(p, "Gemfile"),
|
|
125
125
|
weight=2,
|
|
126
126
|
),
|
|
@@ -215,4 +215,4 @@ def _dep_present_py(project_dir: Path, dep: str) -> bool:
|
|
|
215
215
|
except Exception:
|
|
216
216
|
pass
|
|
217
217
|
|
|
218
|
-
return False
|
|
218
|
+
return False
|
|
@@ -35,6 +35,7 @@ _RULE_REGISTRY: dict[str, str] = {
|
|
|
35
35
|
"python": "contextzip.rules.python",
|
|
36
36
|
"rust": "contextzip.rules.rust",
|
|
37
37
|
"go": "contextzip.rules.go",
|
|
38
|
+
"ruby": "contextzip.rules.ruby",
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
# Files larger than this trigger a warning (but are still included)
|
|
@@ -184,10 +185,10 @@ def resolve_files_from_git(
|
|
|
184
185
|
Build a :class:`ResolveResult` from a pre-selected list of files reported
|
|
185
186
|
by git (modified, added, untracked).
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
Applies the base exclusion spec as a safety floor even though git selected
|
|
189
|
+
the files — a git-tracked ``.env`` or secret key file must still be blocked.
|
|
190
|
+
Size and binary checks are also performed so that the usual warnings appear
|
|
191
|
+
in the CLI output.
|
|
191
192
|
|
|
192
193
|
Parameters
|
|
193
194
|
----------
|
|
@@ -197,6 +198,10 @@ def resolve_files_from_git(
|
|
|
197
198
|
project_dir:
|
|
198
199
|
Absolute path to the project root (used for relative-path guards).
|
|
199
200
|
"""
|
|
201
|
+
# Build the base spec once — this is our safety floor regardless of what
|
|
202
|
+
# git reports as changed. It blocks .env, secrets, binaries, etc.
|
|
203
|
+
base_spec = build_spec(rule_modules=["base"])
|
|
204
|
+
|
|
200
205
|
result = ResolveResult()
|
|
201
206
|
|
|
202
207
|
for abs_path in sorted(git_files):
|
|
@@ -217,11 +222,17 @@ def resolve_files_from_git(
|
|
|
217
222
|
|
|
218
223
|
# ── Guard: must be under project_dir ────────────────────────────────
|
|
219
224
|
try:
|
|
220
|
-
abs_path.relative_to(project_dir)
|
|
225
|
+
rel = abs_path.relative_to(project_dir)
|
|
221
226
|
except ValueError:
|
|
222
227
|
result.skipped.append((abs_path, "outside project tree"))
|
|
223
228
|
continue
|
|
224
229
|
|
|
230
|
+
# ── Base safety floor — block secrets / binaries even if git-tracked ─
|
|
231
|
+
rel_str = rel.as_posix()
|
|
232
|
+
if _any_parent_excluded(rel, base_spec) or base_spec.match_file(rel_str):
|
|
233
|
+
result.excluded.append(abs_path)
|
|
234
|
+
continue
|
|
235
|
+
|
|
225
236
|
# ── Readability / stat check ─────────────────────────────────────────
|
|
226
237
|
try:
|
|
227
238
|
file_size = abs_path.stat().st_size
|
|
@@ -293,4 +304,4 @@ def _is_binary(path: Path) -> bool:
|
|
|
293
304
|
chunk = fh.read(_BINARY_PEEK)
|
|
294
305
|
return b"\x00" in chunk
|
|
295
306
|
except OSError:
|
|
296
|
-
return False
|
|
307
|
+
return False
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
packager.py — Creates the ZIP archive from a ResolveResult.
|
|
3
|
+
|
|
4
|
+
Phase 5 changes:
|
|
5
|
+
- Accepts ResolveResult instead of a bare list[Path]
|
|
6
|
+
- Reports skipped (unreadable / symlink) files to the caller
|
|
7
|
+
- Caps individual file read to avoid runaway memory on huge files
|
|
8
|
+
- Carries skipped_paths forward into PackageResult for CLI display
|
|
9
|
+
|
|
10
|
+
Phase 6 changes:
|
|
11
|
+
- Introduces .contextzip/ workspace directory at the git root (or CWD fallback)
|
|
12
|
+
- Auto-creates .contextzip/ and registers it in .gitignore when inside a git repo
|
|
13
|
+
- Deterministic output names: codebase.zip (default) or changes.zip (--git-changes)
|
|
14
|
+
- --output flag bypasses workspace logic entirely (user owns the path)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import tempfile
|
|
20
|
+
import zipfile
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.progress import (
|
|
26
|
+
BarColumn,
|
|
27
|
+
FileSizeColumn,
|
|
28
|
+
Progress,
|
|
29
|
+
SpinnerColumn,
|
|
30
|
+
TaskProgressColumn,
|
|
31
|
+
TextColumn,
|
|
32
|
+
TimeElapsedColumn,
|
|
33
|
+
TransferSpeedColumn,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from contextzip.filters import ResolveResult
|
|
37
|
+
|
|
38
|
+
# The entry written into .gitignore
|
|
39
|
+
_GITIGNORE_ENTRY = ".contextzip/"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Result model
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PackageResult:
|
|
48
|
+
zip_path: Path
|
|
49
|
+
file_count: int
|
|
50
|
+
uncompressed_bytes: int
|
|
51
|
+
compressed_bytes: int
|
|
52
|
+
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def compression_ratio(self) -> float:
|
|
56
|
+
if self.uncompressed_bytes == 0:
|
|
57
|
+
return 0.0
|
|
58
|
+
return max(0.0, 1.0 - (self.compressed_bytes / self.uncompressed_bytes))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def compression_pct(self) -> str:
|
|
62
|
+
return f"{self.compression_ratio * 100:.0f}%"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def grew(self) -> bool:
|
|
66
|
+
return self.compressed_bytes > self.uncompressed_bytes
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Public API
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def create_zip(
|
|
74
|
+
resolve_result: ResolveResult,
|
|
75
|
+
project_dir: Path,
|
|
76
|
+
output_path: Path | None,
|
|
77
|
+
console: Console,
|
|
78
|
+
git_changes: bool = False,
|
|
79
|
+
) -> PackageResult:
|
|
80
|
+
"""
|
|
81
|
+
Write the included files from *resolve_result* into a ZIP archive.
|
|
82
|
+
|
|
83
|
+
If *output_path* is given (via --output) it is used as-is and the
|
|
84
|
+
.contextzip/ workspace logic is skipped entirely.
|
|
85
|
+
|
|
86
|
+
Otherwise the archive is written to the .contextzip/ workspace
|
|
87
|
+
directory (created automatically) at the git root, or the CWD if
|
|
88
|
+
no git repository is detected.
|
|
89
|
+
|
|
90
|
+
Returns a :class:`PackageResult` with compression stats and any
|
|
91
|
+
files that had to be skipped during writing (e.g. permission denied).
|
|
92
|
+
"""
|
|
93
|
+
if output_path is not None:
|
|
94
|
+
# User specified --output: honour it exactly, no workspace logic.
|
|
95
|
+
zip_path = output_path
|
|
96
|
+
else:
|
|
97
|
+
zip_path = _workspace_output_path(project_dir, git_changes, console)
|
|
98
|
+
|
|
99
|
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
included: list[Path] = resolve_result.included
|
|
102
|
+
skipped_in_zip: list[tuple[Path, str]] = []
|
|
103
|
+
uncompressed = 0
|
|
104
|
+
file_count = 0
|
|
105
|
+
|
|
106
|
+
with Progress(
|
|
107
|
+
SpinnerColumn(),
|
|
108
|
+
TextColumn("[cyan]{task.description}[/]"),
|
|
109
|
+
BarColumn(),
|
|
110
|
+
TaskProgressColumn(),
|
|
111
|
+
FileSizeColumn(),
|
|
112
|
+
TransferSpeedColumn(),
|
|
113
|
+
TimeElapsedColumn(),
|
|
114
|
+
console=console,
|
|
115
|
+
transient=True,
|
|
116
|
+
) as progress:
|
|
117
|
+
|
|
118
|
+
total_bytes = sum(
|
|
119
|
+
p.stat().st_size for p in included if p.is_file()
|
|
120
|
+
)
|
|
121
|
+
task = progress.add_task("Compressing…", total=max(total_bytes, 1))
|
|
122
|
+
|
|
123
|
+
with zipfile.ZipFile(
|
|
124
|
+
zip_path, "w",
|
|
125
|
+
compression=zipfile.ZIP_DEFLATED,
|
|
126
|
+
compresslevel=6,
|
|
127
|
+
) as zf:
|
|
128
|
+
for abs_path in included:
|
|
129
|
+
if not abs_path.is_file():
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
rel = abs_path.relative_to(project_dir)
|
|
134
|
+
except ValueError:
|
|
135
|
+
skipped_in_zip.append((abs_path, "outside project tree"))
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
file_size = abs_path.stat().st_size
|
|
140
|
+
except OSError as e:
|
|
141
|
+
skipped_in_zip.append((abs_path, f"stat failed: {e}"))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
zf.write(abs_path, arcname=rel.as_posix())
|
|
146
|
+
uncompressed += file_size
|
|
147
|
+
file_count += 1
|
|
148
|
+
except PermissionError:
|
|
149
|
+
skipped_in_zip.append((abs_path, "permission denied"))
|
|
150
|
+
except OSError as e:
|
|
151
|
+
skipped_in_zip.append((abs_path, str(e)))
|
|
152
|
+
finally:
|
|
153
|
+
progress.advance(task, file_size)
|
|
154
|
+
|
|
155
|
+
compressed = zip_path.stat().st_size
|
|
156
|
+
|
|
157
|
+
return PackageResult(
|
|
158
|
+
zip_path=zip_path,
|
|
159
|
+
file_count=file_count,
|
|
160
|
+
uncompressed_bytes=uncompressed,
|
|
161
|
+
compressed_bytes=compressed,
|
|
162
|
+
skipped_in_zip=skipped_in_zip,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Workspace helpers
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
171
|
+
"""
|
|
172
|
+
Walk up the directory tree from *start* looking for a .git directory.
|
|
173
|
+
Returns the directory that contains .git, or None if not found.
|
|
174
|
+
"""
|
|
175
|
+
current = start.resolve()
|
|
176
|
+
while True:
|
|
177
|
+
if (current / ".git").is_dir():
|
|
178
|
+
return current
|
|
179
|
+
parent = current.parent
|
|
180
|
+
if parent == current:
|
|
181
|
+
# Reached filesystem root without finding .git
|
|
182
|
+
return None
|
|
183
|
+
current = parent
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ensure_gitignore(git_root: Path) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Ensure .contextzip/ is listed in <git_root>/.gitignore.
|
|
189
|
+
|
|
190
|
+
- If .gitignore exists and already contains the entry: do nothing.
|
|
191
|
+
- If .gitignore exists but lacks the entry: append it.
|
|
192
|
+
- If .gitignore does not exist: create it with just the entry.
|
|
193
|
+
"""
|
|
194
|
+
gitignore_path = git_root / ".gitignore"
|
|
195
|
+
|
|
196
|
+
if gitignore_path.is_file():
|
|
197
|
+
content = gitignore_path.read_text(encoding="utf-8", errors="replace")
|
|
198
|
+
# Check for the entry on its own line (with or without trailing slash variants)
|
|
199
|
+
lines = [line.strip() for line in content.splitlines()]
|
|
200
|
+
if _GITIGNORE_ENTRY in lines or _GITIGNORE_ENTRY.rstrip("/") in lines:
|
|
201
|
+
return # Already present — nothing to do
|
|
202
|
+
# Append, ensuring there's a trailing newline before our entry
|
|
203
|
+
separator = "\n" if content and not content.endswith("\n") else ""
|
|
204
|
+
with gitignore_path.open("a", encoding="utf-8") as f:
|
|
205
|
+
f.write(f"{separator}\n# contextzip workspace\n{_GITIGNORE_ENTRY}\n")
|
|
206
|
+
else:
|
|
207
|
+
# .gitignore doesn't exist — create a minimal one
|
|
208
|
+
gitignore_path.write_text(
|
|
209
|
+
f"# contextzip workspace\n{_GITIGNORE_ENTRY}\n",
|
|
210
|
+
encoding="utf-8",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _workspace_dir(project_dir: Path) -> tuple[Path, bool]:
|
|
215
|
+
"""
|
|
216
|
+
Resolve the .contextzip/ workspace directory.
|
|
217
|
+
|
|
218
|
+
Returns ``(workspace_path, is_git_repo)`` where:
|
|
219
|
+
- workspace_path is <git_root>/.contextzip/ when inside a git repo
|
|
220
|
+
- workspace_path is <project_dir>/.contextzip/ as a fallback
|
|
221
|
+
- is_git_repo indicates whether a git root was found
|
|
222
|
+
"""
|
|
223
|
+
git_root = _find_git_root(project_dir)
|
|
224
|
+
if git_root is not None:
|
|
225
|
+
return git_root / ".contextzip", True
|
|
226
|
+
return project_dir / ".contextzip", False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _workspace_output_path(
|
|
230
|
+
project_dir: Path,
|
|
231
|
+
git_changes: bool,
|
|
232
|
+
console: Console,
|
|
233
|
+
) -> Path:
|
|
234
|
+
"""
|
|
235
|
+
Determine the output ZIP path inside the .contextzip/ workspace.
|
|
236
|
+
|
|
237
|
+
Side effects:
|
|
238
|
+
- Creates the workspace directory if it doesn't exist.
|
|
239
|
+
- Manages .gitignore registration when inside a git repo.
|
|
240
|
+
- Falls back to the system temp directory if the workspace cannot
|
|
241
|
+
be created (e.g. read-only filesystem), printing a warning.
|
|
242
|
+
"""
|
|
243
|
+
workspace, is_git_repo = _workspace_dir(project_dir)
|
|
244
|
+
filename = "changes.zip" if git_changes else "codebase.zip"
|
|
245
|
+
|
|
246
|
+
# Attempt to create the workspace directory
|
|
247
|
+
try:
|
|
248
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
except OSError as exc:
|
|
250
|
+
# Graceful fallback: warn and use temp dir
|
|
251
|
+
console.print(
|
|
252
|
+
f"\n [yellow]⚠[/] Could not create [cyan].contextzip/[/] workspace "
|
|
253
|
+
f"([dim]{exc}[/]) — falling back to temp directory.\n"
|
|
254
|
+
)
|
|
255
|
+
return Path(tempfile.gettempdir()) / filename
|
|
256
|
+
|
|
257
|
+
# Handle .gitignore only when we're inside a git repo
|
|
258
|
+
if is_git_repo:
|
|
259
|
+
git_root = workspace.parent # workspace is <git_root>/.contextzip
|
|
260
|
+
try:
|
|
261
|
+
_ensure_gitignore(git_root)
|
|
262
|
+
except OSError:
|
|
263
|
+
# Non-fatal: gitignore update failed, carry on silently
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
return workspace / filename
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Legacy helper (kept for any internal callers that may reference it)
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def _safe_name(name: str) -> str:
|
|
274
|
+
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
|
|
275
|
+
return safe[:48] or "project"
|
|
@@ -5,7 +5,6 @@ Universal exclusion rules applied to every project regardless of language.
|
|
|
5
5
|
PATTERNS = [
|
|
6
6
|
# Version control
|
|
7
7
|
".git/",
|
|
8
|
-
".gitignore",
|
|
9
8
|
".svn/",
|
|
10
9
|
".hg/",
|
|
11
10
|
|
|
@@ -62,6 +61,20 @@ PATTERNS = [
|
|
|
62
61
|
"*.gz",
|
|
63
62
|
"*.rar",
|
|
64
63
|
|
|
65
|
-
#
|
|
66
|
-
"
|
|
67
|
-
|
|
64
|
+
# contextzip workspace directory — always excluded regardless of .gitignore state
|
|
65
|
+
".contextzip/",
|
|
66
|
+
|
|
67
|
+
# GitHub / repo governance (not useful as AI context)
|
|
68
|
+
"CHANGELOG.md",
|
|
69
|
+
"CHANGELOG",
|
|
70
|
+
"CONTRIBUTING.md",
|
|
71
|
+
"CONTRIBUTING",
|
|
72
|
+
"LICENSE",
|
|
73
|
+
"LICENSE.md",
|
|
74
|
+
"LICENSE.txt",
|
|
75
|
+
"SECURITY.md",
|
|
76
|
+
"CODE_OF_CONDUCT.md",
|
|
77
|
+
".github/ISSUE_TEMPLATE/",
|
|
78
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
79
|
+
".github/PULL_REQUEST_TEMPLATE/",
|
|
80
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ruby / Rails / Bundler exclusion rules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
PATTERNS = [
|
|
6
|
+
# Bundler
|
|
7
|
+
"vendor/bundle/",
|
|
8
|
+
".bundle/",
|
|
9
|
+
"Gemfile.lock",
|
|
10
|
+
|
|
11
|
+
# Ruby version managers
|
|
12
|
+
".ruby-version",
|
|
13
|
+
".ruby-gemset",
|
|
14
|
+
".rvmrc",
|
|
15
|
+
|
|
16
|
+
# Built gems
|
|
17
|
+
"*.gem",
|
|
18
|
+
"pkg/",
|
|
19
|
+
|
|
20
|
+
# Rails-specific
|
|
21
|
+
"tmp/", # runtime temp (also in base, fine to repeat)
|
|
22
|
+
"log/", # request logs (also in base)
|
|
23
|
+
"public/assets/", # precompiled assets (sprockets / propshaft)
|
|
24
|
+
"public/packs/", # webpacker output
|
|
25
|
+
"storage/", # ActiveStorage blobs
|
|
26
|
+
".byebug_history",
|
|
27
|
+
|
|
28
|
+
# Test coverage
|
|
29
|
+
"coverage/",
|
|
30
|
+
|
|
31
|
+
# Documentation
|
|
32
|
+
"doc/",
|
|
33
|
+
"rdoc/",
|
|
34
|
+
|
|
35
|
+
# Spring (Rails app preloader)
|
|
36
|
+
".spring/",
|
|
37
|
+
|
|
38
|
+
# Compiled C extensions
|
|
39
|
+
"*.bundle",
|
|
40
|
+
"*.so", # also in base, explicit here for clarity
|
|
41
|
+
]
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/akadeepesh/contextzip
|
|
8
8
|
Project-URL: Repository, https://github.com/akadeepesh/contextzip
|
|
9
9
|
Project-URL: Issues, https://github.com/akadeepesh/contextzip/issues
|
|
10
|
-
Keywords: ai,cli,developer-tools,context,zip,llm
|
|
10
|
+
Keywords: ai,cli,developer-tools,context,zip,llm,claude,chatgpt,code-packaging
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "contextzip"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Intelligently package your codebase for AI tools"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
|
|
|
13
13
|
authors = [
|
|
14
14
|
{ name = "Deepesh", email = "akadeepesh@gmail.com" }
|
|
15
15
|
]
|
|
16
|
-
keywords = ["ai", "cli", "developer-tools", "context", "zip", "llm"]
|
|
16
|
+
keywords = ["ai", "cli", "developer-tools", "context", "zip", "llm", "claude", "chatgpt", "code-packaging"]
|
|
17
17
|
classifiers = [
|
|
18
18
|
"Development Status :: 4 - Beta",
|
|
19
19
|
"Environment :: Console",
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
packager.py — Creates the ZIP archive from a ResolveResult.
|
|
3
|
-
|
|
4
|
-
Phase 5 changes:
|
|
5
|
-
- Accepts ResolveResult instead of a bare list[Path]
|
|
6
|
-
- Reports skipped (unreadable / symlink) files to the caller
|
|
7
|
-
- Caps individual file read to avoid runaway memory on huge files
|
|
8
|
-
- Carries skipped_paths forward into PackageResult for CLI display
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import tempfile
|
|
14
|
-
import zipfile
|
|
15
|
-
from dataclasses import dataclass, field
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
from rich.console import Console
|
|
20
|
-
from rich.progress import (
|
|
21
|
-
BarColumn,
|
|
22
|
-
FileSizeColumn,
|
|
23
|
-
Progress,
|
|
24
|
-
SpinnerColumn,
|
|
25
|
-
TaskProgressColumn,
|
|
26
|
-
TextColumn,
|
|
27
|
-
TimeElapsedColumn,
|
|
28
|
-
TransferSpeedColumn,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
from contextzip.filters import ResolveResult
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
# Result model
|
|
36
|
-
# ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
@dataclass
|
|
39
|
-
class PackageResult:
|
|
40
|
-
zip_path: Path
|
|
41
|
-
file_count: int
|
|
42
|
-
uncompressed_bytes: int
|
|
43
|
-
compressed_bytes: int
|
|
44
|
-
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
|
|
45
|
-
|
|
46
|
-
@property
|
|
47
|
-
def compression_ratio(self) -> float:
|
|
48
|
-
if self.uncompressed_bytes == 0:
|
|
49
|
-
return 0.0
|
|
50
|
-
return max(0.0, 1.0 - (self.compressed_bytes / self.uncompressed_bytes))
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def compression_pct(self) -> str:
|
|
54
|
-
return f"{self.compression_ratio * 100:.0f}%"
|
|
55
|
-
|
|
56
|
-
@property
|
|
57
|
-
def grew(self) -> bool:
|
|
58
|
-
return self.compressed_bytes > self.uncompressed_bytes
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# ---------------------------------------------------------------------------
|
|
62
|
-
# Public API
|
|
63
|
-
# ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
def create_zip(
|
|
66
|
-
resolve_result: ResolveResult,
|
|
67
|
-
project_dir: Path,
|
|
68
|
-
output_path: Path | None,
|
|
69
|
-
console: Console,
|
|
70
|
-
) -> PackageResult:
|
|
71
|
-
"""
|
|
72
|
-
Write the included files from *resolve_result* into a ZIP archive.
|
|
73
|
-
Returns a :class:`PackageResult` with compression stats and any
|
|
74
|
-
files that had to be skipped during writing (e.g. permission denied).
|
|
75
|
-
"""
|
|
76
|
-
zip_path = output_path or _auto_output_path(project_dir)
|
|
77
|
-
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
|
|
79
|
-
included = resolve_result.included
|
|
80
|
-
skipped_in_zip: list[tuple[Path, str]] = []
|
|
81
|
-
uncompressed = 0
|
|
82
|
-
file_count = 0
|
|
83
|
-
|
|
84
|
-
with Progress(
|
|
85
|
-
SpinnerColumn(),
|
|
86
|
-
TextColumn("[cyan]{task.description}[/]"),
|
|
87
|
-
BarColumn(),
|
|
88
|
-
TaskProgressColumn(),
|
|
89
|
-
FileSizeColumn(),
|
|
90
|
-
TransferSpeedColumn(),
|
|
91
|
-
TimeElapsedColumn(),
|
|
92
|
-
console=console,
|
|
93
|
-
transient=True,
|
|
94
|
-
) as progress:
|
|
95
|
-
|
|
96
|
-
total_bytes = sum(
|
|
97
|
-
p.stat().st_size for p in included if p.is_file()
|
|
98
|
-
)
|
|
99
|
-
task = progress.add_task("Compressing…", total=max(total_bytes, 1))
|
|
100
|
-
|
|
101
|
-
with zipfile.ZipFile(
|
|
102
|
-
zip_path, "w",
|
|
103
|
-
compression=zipfile.ZIP_DEFLATED,
|
|
104
|
-
compresslevel=6,
|
|
105
|
-
) as zf:
|
|
106
|
-
for abs_path in included:
|
|
107
|
-
if not abs_path.is_file():
|
|
108
|
-
continue
|
|
109
|
-
|
|
110
|
-
try:
|
|
111
|
-
rel = abs_path.relative_to(project_dir)
|
|
112
|
-
except ValueError:
|
|
113
|
-
skipped_in_zip.append((abs_path, "outside project tree"))
|
|
114
|
-
continue
|
|
115
|
-
|
|
116
|
-
try:
|
|
117
|
-
file_size = abs_path.stat().st_size
|
|
118
|
-
zf.write(abs_path, arcname=rel.as_posix())
|
|
119
|
-
uncompressed += file_size
|
|
120
|
-
file_count += 1
|
|
121
|
-
except PermissionError:
|
|
122
|
-
skipped_in_zip.append((abs_path, "permission denied"))
|
|
123
|
-
except OSError as e:
|
|
124
|
-
skipped_in_zip.append((abs_path, str(e)))
|
|
125
|
-
finally:
|
|
126
|
-
progress.advance(task, abs_path.stat().st_size if abs_path.exists() else 0)
|
|
127
|
-
|
|
128
|
-
compressed = zip_path.stat().st_size
|
|
129
|
-
|
|
130
|
-
return PackageResult(
|
|
131
|
-
zip_path=zip_path,
|
|
132
|
-
file_count=file_count,
|
|
133
|
-
uncompressed_bytes=uncompressed,
|
|
134
|
-
compressed_bytes=compressed,
|
|
135
|
-
skipped_in_zip=skipped_in_zip,
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# ---------------------------------------------------------------------------
|
|
140
|
-
# Helpers
|
|
141
|
-
# ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
def _auto_output_path(project_dir: Path) -> Path:
|
|
144
|
-
project_name = _safe_name(project_dir.name)
|
|
145
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
146
|
-
filename = f"{project_name}_context_{timestamp}.zip"
|
|
147
|
-
return Path(tempfile.gettempdir()) / filename
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _safe_name(name: str) -> str:
|
|
151
|
-
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
|
|
152
|
-
return safe[:48] or "project"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|