contextzip 0.2.3__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.
Files changed (26) hide show
  1. {contextzip-0.2.3 → contextzip-0.2.4}/PKG-INFO +1 -1
  2. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/__init__.py +1 -1
  3. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/cli.py +2 -1
  4. contextzip-0.2.4/contextzip/packager.py +275 -0
  5. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/base.py +3 -3
  6. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/PKG-INFO +1 -1
  7. {contextzip-0.2.3 → contextzip-0.2.4}/pyproject.toml +1 -1
  8. contextzip-0.2.3/contextzip/packager.py +0 -157
  9. {contextzip-0.2.3 → contextzip-0.2.4}/LICENSE +0 -0
  10. {contextzip-0.2.3 → contextzip-0.2.4}/README.md +0 -0
  11. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/clipboard.py +0 -0
  12. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/detector.py +0 -0
  13. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/filters.py +0 -0
  14. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/git.py +0 -0
  15. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/__init__.py +0 -0
  16. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/go.py +0 -0
  17. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/node.py +0 -0
  18. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/python.py +0 -0
  19. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/ruby.py +0 -0
  20. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/rust.py +0 -0
  21. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/SOURCES.txt +0 -0
  22. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/dependency_links.txt +0 -0
  23. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/entry_points.txt +0 -0
  24. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/requires.txt +0 -0
  25. {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/top_level.txt +0 -0
  26. {contextzip-0.2.3 → contextzip-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.2.3
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
@@ -1,3 +1,3 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -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. Defaults to <project>_context_<timestamp>.zip in temp dir.",
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}")
@@ -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"
@@ -61,8 +61,8 @@ PATTERNS = [
61
61
  "*.gz",
62
62
  "*.rar",
63
63
 
64
- # The output zip itself (avoid recursion)
65
- "*_context_*.zip",
64
+ # contextzip workspace directory — always excluded regardless of .gitignore state
65
+ ".contextzip/",
66
66
 
67
67
  # GitHub / repo governance (not useful as AI context)
68
68
  "CHANGELOG.md",
@@ -77,4 +77,4 @@ PATTERNS = [
77
77
  ".github/ISSUE_TEMPLATE/",
78
78
  ".github/PULL_REQUEST_TEMPLATE.md",
79
79
  ".github/PULL_REQUEST_TEMPLATE/",
80
- ]
80
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.2.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.2.3"
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"
@@ -1,157 +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
- except OSError as e:
119
- skipped_in_zip.append((abs_path, f"stat failed: {e}"))
120
- continue
121
-
122
- try:
123
- zf.write(abs_path, arcname=rel.as_posix())
124
- uncompressed += file_size
125
- file_count += 1
126
- except PermissionError:
127
- skipped_in_zip.append((abs_path, "permission denied"))
128
- except OSError as e:
129
- skipped_in_zip.append((abs_path, str(e)))
130
- finally:
131
- progress.advance(task, file_size)
132
-
133
- compressed = zip_path.stat().st_size
134
-
135
- return PackageResult(
136
- zip_path=zip_path,
137
- file_count=file_count,
138
- uncompressed_bytes=uncompressed,
139
- compressed_bytes=compressed,
140
- skipped_in_zip=skipped_in_zip,
141
- )
142
-
143
-
144
- # ---------------------------------------------------------------------------
145
- # Helpers
146
- # ---------------------------------------------------------------------------
147
-
148
- def _auto_output_path(project_dir: Path) -> Path:
149
- project_name = _safe_name(project_dir.name)
150
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
151
- filename = f"{project_name}_context_{timestamp}.zip"
152
- return Path(tempfile.gettempdir()) / filename
153
-
154
-
155
- def _safe_name(name: str) -> str:
156
- safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
157
- return safe[:48] or "project"
File without changes
File without changes
File without changes
File without changes