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.
- {contextzip-0.2.3 → contextzip-0.2.4}/PKG-INFO +1 -1
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/__init__.py +1 -1
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/cli.py +2 -1
- contextzip-0.2.4/contextzip/packager.py +275 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/base.py +3 -3
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/PKG-INFO +1 -1
- {contextzip-0.2.3 → contextzip-0.2.4}/pyproject.toml +1 -1
- contextzip-0.2.3/contextzip/packager.py +0 -157
- {contextzip-0.2.3 → contextzip-0.2.4}/LICENSE +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/README.md +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/clipboard.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/detector.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/filters.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/git.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/go.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/node.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/python.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip/rules/rust.py +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/SOURCES.txt +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.2.3 → contextzip-0.2.4}/setup.cfg +0 -0
|
@@ -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}")
|
|
@@ -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
|
-
#
|
|
65
|
-
"
|
|
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,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
|
|
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
|