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.
Files changed (26) hide show
  1. {contextzip-0.2.2 → contextzip-0.2.4}/PKG-INFO +2 -2
  2. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/__init__.py +1 -1
  3. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/cli.py +2 -1
  4. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/detector.py +2 -2
  5. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/filters.py +17 -6
  6. contextzip-0.2.4/contextzip/packager.py +275 -0
  7. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/base.py +17 -4
  8. contextzip-0.2.4/contextzip/rules/ruby.py +41 -0
  9. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/PKG-INFO +2 -2
  10. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/SOURCES.txt +1 -0
  11. {contextzip-0.2.2 → contextzip-0.2.4}/pyproject.toml +2 -2
  12. contextzip-0.2.2/contextzip/packager.py +0 -152
  13. {contextzip-0.2.2 → contextzip-0.2.4}/LICENSE +0 -0
  14. {contextzip-0.2.2 → contextzip-0.2.4}/README.md +0 -0
  15. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/clipboard.py +0 -0
  16. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/git.py +0 -0
  17. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/__init__.py +0 -0
  18. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/go.py +0 -0
  19. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/node.py +0 -0
  20. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/python.py +0 -0
  21. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip/rules/rust.py +0 -0
  22. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/dependency_links.txt +0 -0
  23. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/entry_points.txt +0 -0
  24. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/requires.txt +0 -0
  25. {contextzip-0.2.2 → contextzip-0.2.4}/contextzip.egg-info/top_level.txt +0 -0
  26. {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.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
@@ -1,3 +1,3 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.2.2"
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}")
@@ -120,7 +120,7 @@ _RULES: list[_Rule] = [
120
120
  # ── Ruby ─────────────────────────────────────────────────────────────
121
121
  _Rule(
122
122
  name="Ruby",
123
- module="base", # no dedicated rule module yet; base covers it
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
- Unlike :func:`resolve_files`, this function does **not** apply exclusion
188
- specsthe caller has already determined which files to include via
189
- ``git status``. Size and binary checks are still performed so that the
190
- usual warnings appear in the CLI output.
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
- # The output zip itself (avoid recursion)
66
- "*_context_*.zip",
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.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
@@ -19,4 +19,5 @@ contextzip/rules/base.py
19
19
  contextzip/rules/go.py
20
20
  contextzip/rules/node.py
21
21
  contextzip/rules/python.py
22
+ contextzip/rules/ruby.py
22
23
  contextzip/rules/rust.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.2.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