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.
Files changed (36) hide show
  1. {contextzip-0.3.1 → contextzip-0.3.2}/PKG-INFO +1 -1
  2. contextzip-0.3.2/contextzip/__init__.py +34 -0
  3. contextzip-0.3.2/contextzip/api.py +400 -0
  4. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/packager.py +100 -0
  5. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/PKG-INFO +1 -1
  6. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/SOURCES.txt +1 -0
  7. {contextzip-0.3.1 → contextzip-0.3.2}/pyproject.toml +1 -1
  8. contextzip-0.3.1/contextzip/__init__.py +0 -3
  9. {contextzip-0.3.1 → contextzip-0.3.2}/LICENSE +0 -0
  10. {contextzip-0.3.1 → contextzip-0.3.2}/README.md +0 -0
  11. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli.py +0 -0
  12. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_ai.py +0 -0
  13. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_display.py +0 -0
  14. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/cli_onboard.py +0 -0
  15. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/clipboard.py +0 -0
  16. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/config.py +0 -0
  17. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/detector.py +0 -0
  18. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/error_parser.py +0 -0
  19. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/filters.py +0 -0
  20. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/git.py +0 -0
  21. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/__init__.py +0 -0
  22. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/base.py +0 -0
  23. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/__init__.py +0 -0
  24. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/node.py +0 -0
  25. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/errors/python.py +0 -0
  26. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/go.py +0 -0
  27. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/node.py +0 -0
  28. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/python.py +0 -0
  29. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/ruby.py +0 -0
  30. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/rules/rust.py +0 -0
  31. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip/watcher.py +0 -0
  32. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/dependency_links.txt +0 -0
  33. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/entry_points.txt +0 -0
  34. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/requires.txt +0 -0
  35. {contextzip-0.3.1 → contextzip-0.3.2}/contextzip.egg-info/top_level.txt +0 -0
  36. {contextzip-0.3.1 → contextzip-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  contextzip/__init__.py
5
+ contextzip/api.py
5
6
  contextzip/cli.py
6
7
  contextzip/cli_ai.py
7
8
  contextzip/cli_display.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Intelligently package your codebase for AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +0,0 @@
1
- """contextzip — intelligent codebase packager for AI tools."""
2
-
3
- __version__ = "0.3.1"
File without changes
File without changes
File without changes
File without changes
File without changes