contextzip 0.3.1__tar.gz → 0.3.2__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.3.1 → contextzip-0.3.2}/PKG-INFO +1 -1
- contextzip-0.3.2/contextzip/__init__.py +34 -0
- contextzip-0.3.2/contextzip/api.py +400 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/packager.py +100 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/PKG-INFO +1 -1
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/pyproject.toml +1 -1
- contextzip-0.3.1/contextzip/__init__.py +0 -3
- {contextzip-0.3.1 → contextzip-0.3.2}/LICENSE +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/README.md +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_ai.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_display.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/clipboard.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/config.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/detector.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/filters.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/git.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/base.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/watcher.py +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.3.1 → contextzip-0.3.2}/setup.cfg +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""contextzip — intelligent codebase packager for AI tools."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.2"
|
|
4
|
+
|
|
5
|
+
from contextzip.api import (
|
|
6
|
+
FileCollection,
|
|
7
|
+
ContextzipError,
|
|
8
|
+
NotARepositoryError,
|
|
9
|
+
GitNotFoundError,
|
|
10
|
+
GitCommandError,
|
|
11
|
+
NoFilesError,
|
|
12
|
+
get_git_changes,
|
|
13
|
+
get_files,
|
|
14
|
+
create_zip,
|
|
15
|
+
detect_ecosystem,
|
|
16
|
+
)
|
|
17
|
+
from contextzip.packager import PackageResult
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Functions
|
|
21
|
+
"get_git_changes",
|
|
22
|
+
"get_files",
|
|
23
|
+
"create_zip",
|
|
24
|
+
"detect_ecosystem",
|
|
25
|
+
# Data types
|
|
26
|
+
"FileCollection",
|
|
27
|
+
"PackageResult",
|
|
28
|
+
# Exceptions
|
|
29
|
+
"ContextzipError",
|
|
30
|
+
"NotARepositoryError",
|
|
31
|
+
"GitNotFoundError",
|
|
32
|
+
"GitCommandError",
|
|
33
|
+
"NoFilesError",
|
|
34
|
+
]
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api.py — Public Python API for contextzip.
|
|
3
|
+
|
|
4
|
+
Exposes the same capabilities as the CLI but as plain Python functions:
|
|
5
|
+
no Click, no Rich output, no SystemExit. All functions raise exceptions
|
|
6
|
+
on failure so callers can handle errors in their own way.
|
|
7
|
+
|
|
8
|
+
Quickstart
|
|
9
|
+
──────────
|
|
10
|
+
from contextzip import get_git_changes, get_files, create_zip
|
|
11
|
+
|
|
12
|
+
# Get git-changed files and use them directly (no zip needed)
|
|
13
|
+
collection = get_git_changes()
|
|
14
|
+
for path in collection.files:
|
|
15
|
+
upload(path) # plain pathlib.Path objects
|
|
16
|
+
|
|
17
|
+
# Or zip them
|
|
18
|
+
pkg = create_zip(collection, output="/tmp/changes.zip")
|
|
19
|
+
with open(pkg.zip_path, "rb") as f:
|
|
20
|
+
upload_to_s3(f)
|
|
21
|
+
|
|
22
|
+
# Get all project files (respecting .gitignore and built-in rules)
|
|
23
|
+
collection = get_files()
|
|
24
|
+
for path in collection.files:
|
|
25
|
+
print(path)
|
|
26
|
+
|
|
27
|
+
# Narrow it down
|
|
28
|
+
collection = get_files(
|
|
29
|
+
include=["src/", "app/"],
|
|
30
|
+
exclude=["tests/", "*.log"],
|
|
31
|
+
)
|
|
32
|
+
pkg = create_zip(collection, output="/tmp/upload.zip")
|
|
33
|
+
print(f"Packed {pkg.file_count} files → {pkg.zip_path}")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from contextzip.detector import DetectionResult, detect
|
|
43
|
+
from contextzip.filters import (
|
|
44
|
+
ResolveResult,
|
|
45
|
+
build_spec,
|
|
46
|
+
resolve_files,
|
|
47
|
+
resolve_files_from_git,
|
|
48
|
+
)
|
|
49
|
+
from contextzip.git import GitChanges, GitError, GitErrorKind, get_changed_files
|
|
50
|
+
from contextzip.packager import PackageResult, create_zip_silent
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Public result type
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class FileCollection:
|
|
60
|
+
"""
|
|
61
|
+
A resolved set of files returned by :func:`get_git_changes` or
|
|
62
|
+
:func:`get_files`.
|
|
63
|
+
|
|
64
|
+
``files`` is always a plain list of absolute :class:`pathlib.Path` objects
|
|
65
|
+
— use them directly for uploads, processing, or anything else.
|
|
66
|
+
Zipping is optional: pass this object to :func:`create_zip` if needed.
|
|
67
|
+
|
|
68
|
+
Attributes
|
|
69
|
+
----------
|
|
70
|
+
files:
|
|
71
|
+
Absolute paths of all files in this collection. These are the
|
|
72
|
+
files that would be (or were) included in a ZIP.
|
|
73
|
+
skipped:
|
|
74
|
+
Files that were silently skipped during resolution, as
|
|
75
|
+
``(path, reason)`` tuples (e.g. dangling symlinks, unreadable files).
|
|
76
|
+
large_files:
|
|
77
|
+
Files exceeding 1 MB, as ``(path, size_in_bytes)`` tuples.
|
|
78
|
+
They are still included in ``files`` — this is informational only.
|
|
79
|
+
binary_files:
|
|
80
|
+
Files that appear to be binary (contain null bytes). Still included
|
|
81
|
+
in ``files`` — informational only.
|
|
82
|
+
project_dir:
|
|
83
|
+
The project root used when resolving paths.
|
|
84
|
+
ecosystem:
|
|
85
|
+
Detected ecosystem string, e.g. ``"Next.js + Node.js"``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
files: list[Path] = field(default_factory=list)
|
|
89
|
+
skipped: list[tuple[Path, str]] = field(default_factory=list)
|
|
90
|
+
large_files: list[tuple[Path, int]] = field(default_factory=list)
|
|
91
|
+
binary_files: list[Path] = field(default_factory=list)
|
|
92
|
+
project_dir: Path = field(default_factory=Path.cwd)
|
|
93
|
+
ecosystem: str = "Unknown"
|
|
94
|
+
|
|
95
|
+
def __len__(self) -> int:
|
|
96
|
+
return len(self.files)
|
|
97
|
+
|
|
98
|
+
def __iter__(self):
|
|
99
|
+
return iter(self.files)
|
|
100
|
+
|
|
101
|
+
def __bool__(self) -> bool:
|
|
102
|
+
return bool(self.files)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Exceptions
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ContextzipError(Exception):
|
|
111
|
+
"""Base class for all contextzip API errors."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class NotARepositoryError(ContextzipError):
|
|
115
|
+
"""Raised when the project directory is not inside a git repository."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class GitNotFoundError(ContextzipError):
|
|
119
|
+
"""Raised when git is not installed or not on PATH."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class GitCommandError(ContextzipError):
|
|
123
|
+
"""Raised when a git command fails unexpectedly."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class NoFilesError(ContextzipError):
|
|
127
|
+
"""Raised when file resolution produces an empty result."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Public API
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_git_changes(
|
|
136
|
+
path: str | Path | None = None,
|
|
137
|
+
) -> FileCollection:
|
|
138
|
+
"""
|
|
139
|
+
Return the files that git reports as modified, added, or untracked.
|
|
140
|
+
|
|
141
|
+
The git root is found automatically by walking up from *path* (or the
|
|
142
|
+
current working directory). Files are filtered through contextzip's base
|
|
143
|
+
safety rules so secrets, binaries, and similar files are always excluded.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
path:
|
|
148
|
+
Directory to start from. Defaults to ``Path.cwd()``. The actual
|
|
149
|
+
git root may be a parent of this directory.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
FileCollection
|
|
154
|
+
``collection.files`` contains absolute :class:`~pathlib.Path` objects
|
|
155
|
+
for every changed file. Iterate over it or pass it to
|
|
156
|
+
:func:`create_zip`.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
GitNotFoundError
|
|
161
|
+
If git is not installed or not on PATH.
|
|
162
|
+
NotARepositoryError
|
|
163
|
+
If *path* is not inside a git repository.
|
|
164
|
+
GitCommandError
|
|
165
|
+
If ``git status`` fails for any other reason.
|
|
166
|
+
|
|
167
|
+
Example
|
|
168
|
+
-------
|
|
169
|
+
::
|
|
170
|
+
|
|
171
|
+
from contextzip import get_git_changes
|
|
172
|
+
|
|
173
|
+
collection = get_git_changes()
|
|
174
|
+
for f in collection.files:
|
|
175
|
+
print(f) # plain pathlib.Path — use however you like
|
|
176
|
+
|
|
177
|
+
# Only staged files
|
|
178
|
+
for rel in collection._git_changes.staged:
|
|
179
|
+
print(rel)
|
|
180
|
+
"""
|
|
181
|
+
project_dir = _resolve_dir(path)
|
|
182
|
+
git_result = get_changed_files(project_dir)
|
|
183
|
+
|
|
184
|
+
if isinstance(git_result, GitError):
|
|
185
|
+
_raise_git_error(git_result)
|
|
186
|
+
|
|
187
|
+
if git_result.is_empty:
|
|
188
|
+
return FileCollection(project_dir=project_dir)
|
|
189
|
+
|
|
190
|
+
resolved = resolve_files_from_git(
|
|
191
|
+
git_files=git_result.files,
|
|
192
|
+
project_dir=project_dir,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
detection = detect(project_dir)
|
|
196
|
+
|
|
197
|
+
collection = _resolve_result_to_collection(resolved, project_dir, detection)
|
|
198
|
+
# Stash the raw GitChanges on the collection for callers who want
|
|
199
|
+
# staged/unstaged/untracked breakdowns without accessing internals
|
|
200
|
+
collection._git_changes = git_result # type: ignore[attr-defined]
|
|
201
|
+
return collection
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_files(
|
|
205
|
+
path: str | Path | None = None,
|
|
206
|
+
*,
|
|
207
|
+
include: list[str] | None = None,
|
|
208
|
+
exclude: list[str] | None = None,
|
|
209
|
+
use_gitignore: bool = True,
|
|
210
|
+
) -> FileCollection:
|
|
211
|
+
"""
|
|
212
|
+
Return all project files after applying contextzip's standard exclusion rules.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
path:
|
|
217
|
+
Project root to scan. Defaults to ``Path.cwd()``.
|
|
218
|
+
include:
|
|
219
|
+
If given, only files under these paths are returned (e.g.
|
|
220
|
+
``["src/", "app/"]``). Matched as exact path prefixes.
|
|
221
|
+
exclude:
|
|
222
|
+
Extra exclusion patterns on top of the auto-detected rules
|
|
223
|
+
(gitignore syntax, e.g. ``["tests/", "*.log"]``).
|
|
224
|
+
use_gitignore:
|
|
225
|
+
Whether to apply the project's ``.gitignore`` file.
|
|
226
|
+
Defaults to ``True``.
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
FileCollection
|
|
231
|
+
``collection.files`` contains absolute :class:`~pathlib.Path` objects
|
|
232
|
+
for every included file.
|
|
233
|
+
|
|
234
|
+
Example
|
|
235
|
+
-------
|
|
236
|
+
::
|
|
237
|
+
|
|
238
|
+
from contextzip import get_files
|
|
239
|
+
|
|
240
|
+
# All project files
|
|
241
|
+
collection = get_files()
|
|
242
|
+
|
|
243
|
+
# Only src/, excluding tests
|
|
244
|
+
collection = get_files(include=["src/"], exclude=["tests/"])
|
|
245
|
+
|
|
246
|
+
for f in collection.files:
|
|
247
|
+
process(f)
|
|
248
|
+
"""
|
|
249
|
+
project_dir = _resolve_dir(path)
|
|
250
|
+
detection = detect(project_dir)
|
|
251
|
+
|
|
252
|
+
gitignore_path = (project_dir / ".gitignore") if use_gitignore else None
|
|
253
|
+
|
|
254
|
+
spec = build_spec(
|
|
255
|
+
rule_modules=detection.rule_modules,
|
|
256
|
+
extra_exclude=exclude or None,
|
|
257
|
+
gitignore_path=gitignore_path,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
resolved = resolve_files(
|
|
261
|
+
project_dir=project_dir,
|
|
262
|
+
spec=spec,
|
|
263
|
+
include_only=include or None,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return _resolve_result_to_collection(resolved, project_dir, detection)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def create_zip(
|
|
270
|
+
collection: FileCollection,
|
|
271
|
+
output: str | Path | None = None,
|
|
272
|
+
) -> PackageResult:
|
|
273
|
+
"""
|
|
274
|
+
Write *collection* into a ZIP archive and return the result.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
collection:
|
|
279
|
+
A :class:`FileCollection` returned by :func:`get_git_changes` or
|
|
280
|
+
:func:`get_files`.
|
|
281
|
+
output:
|
|
282
|
+
Where to write the ZIP. If omitted, the archive is written to the
|
|
283
|
+
``.contextzip/`` workspace at the project root (same as the CLI).
|
|
284
|
+
Pass an explicit path to control where it lands — useful when you
|
|
285
|
+
want to write to a temp directory before uploading.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
PackageResult
|
|
290
|
+
Contains ``zip_path`` (a :class:`~pathlib.Path`), ``file_count``,
|
|
291
|
+
``compressed_bytes``, ``uncompressed_bytes``, and ``skipped_in_zip``.
|
|
292
|
+
|
|
293
|
+
Raises
|
|
294
|
+
------
|
|
295
|
+
NoFilesError
|
|
296
|
+
If *collection* is empty (nothing to zip).
|
|
297
|
+
OSError
|
|
298
|
+
If the ZIP file cannot be written.
|
|
299
|
+
|
|
300
|
+
Example
|
|
301
|
+
-------
|
|
302
|
+
::
|
|
303
|
+
|
|
304
|
+
import tempfile
|
|
305
|
+
from contextzip import get_git_changes, create_zip
|
|
306
|
+
|
|
307
|
+
collection = get_git_changes()
|
|
308
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
309
|
+
pkg = create_zip(collection, output=tmp.name)
|
|
310
|
+
|
|
311
|
+
with open(pkg.zip_path, "rb") as f:
|
|
312
|
+
upload_to_s3(f.read())
|
|
313
|
+
|
|
314
|
+
print(f"Packed {pkg.file_count} files, {pkg.compressed_bytes} bytes compressed")
|
|
315
|
+
"""
|
|
316
|
+
if not collection.files:
|
|
317
|
+
raise NoFilesError(
|
|
318
|
+
"The FileCollection is empty — nothing to zip. "
|
|
319
|
+
"Check get_git_changes() or get_files() returned files."
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
resolve_result = ResolveResult(
|
|
323
|
+
included=collection.files,
|
|
324
|
+
skipped=collection.skipped,
|
|
325
|
+
large_files=collection.large_files,
|
|
326
|
+
binary_files=collection.binary_files,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
output_path = Path(output).resolve() if output is not None else None
|
|
330
|
+
|
|
331
|
+
return create_zip_silent(
|
|
332
|
+
resolve_result=resolve_result,
|
|
333
|
+
project_dir=collection.project_dir,
|
|
334
|
+
output_path=output_path,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def detect_ecosystem(
|
|
339
|
+
path: str | Path | None = None,
|
|
340
|
+
) -> DetectionResult:
|
|
341
|
+
"""
|
|
342
|
+
Detect the ecosystem(s) present in a project directory.
|
|
343
|
+
|
|
344
|
+
Parameters
|
|
345
|
+
----------
|
|
346
|
+
path:
|
|
347
|
+
Directory to inspect. Defaults to ``Path.cwd()``.
|
|
348
|
+
|
|
349
|
+
Returns
|
|
350
|
+
-------
|
|
351
|
+
DetectionResult
|
|
352
|
+
Has ``.ecosystems`` (list of strings like ``["Next.js", "Node.js"]``),
|
|
353
|
+
``.display_name`` (e.g. ``"Next.js + Node.js"``), and
|
|
354
|
+
``.confidence`` (``"low"`` / ``"medium"`` / ``"high"``).
|
|
355
|
+
|
|
356
|
+
Example
|
|
357
|
+
-------
|
|
358
|
+
::
|
|
359
|
+
|
|
360
|
+
from contextzip import detect_ecosystem
|
|
361
|
+
|
|
362
|
+
result = detect_ecosystem("/path/to/project")
|
|
363
|
+
print(result.display_name) # "Django + Python"
|
|
364
|
+
print(result.confidence) # "high"
|
|
365
|
+
"""
|
|
366
|
+
return detect(_resolve_dir(path))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Internal helpers
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _resolve_dir(path: str | Path | None) -> Path:
|
|
375
|
+
if path is None:
|
|
376
|
+
return Path(os.getcwd()).resolve()
|
|
377
|
+
return Path(path).resolve()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _resolve_result_to_collection(
|
|
381
|
+
resolved: ResolveResult,
|
|
382
|
+
project_dir: Path,
|
|
383
|
+
detection: DetectionResult,
|
|
384
|
+
) -> FileCollection:
|
|
385
|
+
return FileCollection(
|
|
386
|
+
files=resolved.included,
|
|
387
|
+
skipped=resolved.skipped,
|
|
388
|
+
large_files=resolved.large_files,
|
|
389
|
+
binary_files=resolved.binary_files,
|
|
390
|
+
project_dir=project_dir,
|
|
391
|
+
ecosystem=detection.display_name,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _raise_git_error(error: GitError) -> None:
|
|
396
|
+
if error.kind == GitErrorKind.GIT_NOT_FOUND:
|
|
397
|
+
raise GitNotFoundError(error.message)
|
|
398
|
+
if error.kind == GitErrorKind.NOT_A_REPO:
|
|
399
|
+
raise NotARepositoryError(error.message)
|
|
400
|
+
raise GitCommandError(error.message)
|
|
@@ -72,6 +72,78 @@ class PackageResult:
|
|
|
72
72
|
# ---------------------------------------------------------------------------
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def create_zip_silent(
|
|
76
|
+
resolve_result: ResolveResult,
|
|
77
|
+
project_dir: Path,
|
|
78
|
+
output_path: Path | None,
|
|
79
|
+
git_changes: bool = False,
|
|
80
|
+
prompt_txt: str | None = None,
|
|
81
|
+
) -> PackageResult:
|
|
82
|
+
"""
|
|
83
|
+
Write the included files from *resolve_result* into a ZIP archive
|
|
84
|
+
without any console/progress output. Intended for programmatic use.
|
|
85
|
+
|
|
86
|
+
Identical to :func:`create_zip` except it produces no Rich output —
|
|
87
|
+
safe to call in scripts, background threads, or anywhere a TTY isn't
|
|
88
|
+
available.
|
|
89
|
+
"""
|
|
90
|
+
if output_path is not None:
|
|
91
|
+
zip_path = output_path
|
|
92
|
+
else:
|
|
93
|
+
zip_path = _workspace_output_path_silent(project_dir, git_changes)
|
|
94
|
+
|
|
95
|
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
included: list[Path] = resolve_result.included
|
|
98
|
+
skipped_in_zip: list[tuple[Path, str]] = []
|
|
99
|
+
uncompressed = 0
|
|
100
|
+
file_count = 0
|
|
101
|
+
|
|
102
|
+
with zipfile.ZipFile(
|
|
103
|
+
zip_path,
|
|
104
|
+
"w",
|
|
105
|
+
compression=zipfile.ZIP_DEFLATED,
|
|
106
|
+
compresslevel=6,
|
|
107
|
+
) as zf:
|
|
108
|
+
if prompt_txt is not None:
|
|
109
|
+
zf.writestr("prompt.txt", prompt_txt.encode("utf-8"))
|
|
110
|
+
|
|
111
|
+
for abs_path in included:
|
|
112
|
+
if not abs_path.is_file():
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
rel = abs_path.relative_to(project_dir)
|
|
117
|
+
except ValueError:
|
|
118
|
+
skipped_in_zip.append((abs_path, "outside project tree"))
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
file_size = abs_path.stat().st_size
|
|
123
|
+
except OSError as e:
|
|
124
|
+
skipped_in_zip.append((abs_path, f"stat failed: {e}"))
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
zf.write(abs_path, arcname=rel.as_posix())
|
|
129
|
+
uncompressed += file_size
|
|
130
|
+
file_count += 1
|
|
131
|
+
except PermissionError:
|
|
132
|
+
skipped_in_zip.append((abs_path, "permission denied"))
|
|
133
|
+
except OSError as e:
|
|
134
|
+
skipped_in_zip.append((abs_path, str(e)))
|
|
135
|
+
|
|
136
|
+
compressed = zip_path.stat().st_size
|
|
137
|
+
|
|
138
|
+
return PackageResult(
|
|
139
|
+
zip_path=zip_path,
|
|
140
|
+
file_count=file_count,
|
|
141
|
+
uncompressed_bytes=uncompressed,
|
|
142
|
+
compressed_bytes=compressed,
|
|
143
|
+
skipped_in_zip=skipped_in_zip,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
75
147
|
def create_zip(
|
|
76
148
|
resolve_result: ResolveResult,
|
|
77
149
|
project_dir: Path,
|
|
@@ -236,6 +308,34 @@ def _workspace_dir(project_dir: Path) -> tuple[Path, bool]:
|
|
|
236
308
|
return project_dir / ".contextzip", False
|
|
237
309
|
|
|
238
310
|
|
|
311
|
+
def _workspace_output_path_silent(
|
|
312
|
+
project_dir: Path,
|
|
313
|
+
git_changes: bool,
|
|
314
|
+
) -> Path:
|
|
315
|
+
"""
|
|
316
|
+
Determine the output ZIP path inside the .contextzip/ workspace,
|
|
317
|
+
without printing any warnings (for programmatic/API use).
|
|
318
|
+
|
|
319
|
+
Falls back to the system temp directory if the workspace cannot be created.
|
|
320
|
+
"""
|
|
321
|
+
workspace, is_git_repo = _workspace_dir(project_dir)
|
|
322
|
+
filename = "changes.zip" if git_changes else "codebase.zip"
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
except OSError:
|
|
327
|
+
return Path(tempfile.gettempdir()) / filename
|
|
328
|
+
|
|
329
|
+
if is_git_repo:
|
|
330
|
+
git_root = workspace.parent
|
|
331
|
+
try:
|
|
332
|
+
_ensure_gitignore(git_root)
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
return workspace / filename
|
|
337
|
+
|
|
338
|
+
|
|
239
339
|
def _workspace_output_path(
|
|
240
340
|
project_dir: Path,
|
|
241
341
|
git_changes: bool,
|
|
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
|
|
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
|