zipwrap 1.0.2__py3-none-any.whl

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.
zipwrap/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ zipwrap
3
+ -------
4
+
5
+ A config-driven wrapper around the Linux `zip` CLI
6
+
7
+ Usage:
8
+ zipwrap --config config.json
9
+ zipwrap --root . --include "*.py" --exclude "venv/**" --outfile dist/code.zip --recurse --compression 9
10
+ """
11
+ DEFAULTS = {
12
+ "root": ".",
13
+ "include": ["*"],
14
+ "exclude": [".venv/**", "venv/**", "*.zip"],
15
+ "outfile": "archive.zip",
16
+ "recurse": True,
17
+ "compression": 9,
18
+ }
19
+
20
+ __all__ = [DEFAULTS]
zipwrap/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zipwrap.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
zipwrap/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ zipwrap.cli
4
+ ===========
5
+
6
+ A config-driven wrapper around the Linux `zip` command for Linux environments.
7
+
8
+ Config schema (dict[str, list[str] | str | bool | int]):
9
+ {
10
+ "root": ".",
11
+ "include": ["*"],
12
+ "exclude": [".venv/**", "venv/**", "*.zip"],
13
+ "outfile": "archive.zip",
14
+ "recurse": true,
15
+ "compression": 9
16
+ }
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import fnmatch
22
+ import logging
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Iterable, List, Sequence, Tuple
28
+
29
+ from tqdm import tqdm
30
+
31
+ from zipwrap.config import Config
32
+ from zipwrap import DEFAULTS # noqa: F401
33
+
34
+
35
+ def _zip_available() -> bool:
36
+ """Check whether the `zip` executable exists in PATH."""
37
+ return shutil.which("zip") is not None
38
+
39
+
40
+ def _glob_many(root: Path, patterns: Sequence[str], recurse: bool) -> List[Path]:
41
+ """
42
+ Resolve many glob patterns (files only). If `recurse` is True, patterns are used with rglob; otherwise glob.
43
+ Patterns are evaluated relative to `root`.
44
+ """
45
+ results: list[Path] = []
46
+ for pat in patterns:
47
+ try:
48
+ iterator = root.rglob(pat) if recurse else root.glob(pat)
49
+ for p in iterator:
50
+ if p.is_file():
51
+ results.append(p.resolve())
52
+ except Exception:
53
+ continue
54
+ return results
55
+
56
+
57
+ def _filter_excludes(root: Path, files: Sequence[Path], exclude_patterns: Sequence[str], recurse: bool) -> List[Path]:
58
+ """
59
+ Remove files matching any exclude pattern. We match against POSIX-style paths relative to root.
60
+ Using fnmatch for flexible wildcards, applied to relative strings.
61
+ """
62
+ rel_map = {f: f.relative_to(root).as_posix() for f in files}
63
+ excluded_rel: set[str] = set()
64
+
65
+ # Precompute excluded sets via glob for correctness with ** semantics.
66
+ excluded_files = set(_glob_many(root, exclude_patterns, recurse))
67
+ for f in excluded_files:
68
+ if f in rel_map:
69
+ excluded_rel.add(rel_map[f])
70
+
71
+ # Also honor fnmatch for patterns that didn't arise from glob
72
+ out: list[Path] = []
73
+ for f, rel in rel_map.items():
74
+ if any(fnmatch.fnmatch(rel, pat) for pat in exclude_patterns):
75
+ continue
76
+ if rel in excluded_rel:
77
+ continue
78
+ out.append(f)
79
+ return out
80
+
81
+
82
+ def collect_files(root: Path, include: Sequence[str], exclude: Sequence[str], recurse: bool, outfile: Path | None = None) -> List[Path]:
83
+ """
84
+ Compute the final list of files to zip.
85
+ """
86
+ included = _glob_many(root, include, recurse)
87
+ filtered = _filter_excludes(root, included, exclude, recurse)
88
+
89
+ if outfile:
90
+ try:
91
+ if outfile.is_relative_to(root):
92
+ filtered = [p for p in filtered if p.resolve() != outfile.resolve()]
93
+ except AttributeError:
94
+ # py3.10 fallback
95
+ try:
96
+ outfile.relative_to(root)
97
+ filtered = [p for p in filtered if p.resolve() != outfile.resolve()]
98
+ except ValueError:
99
+ pass
100
+
101
+ # Deduplicate while retaining stable order
102
+ seen: set[Path] = set()
103
+ unique: list[Path] = []
104
+ for p in filtered:
105
+ if p not in seen:
106
+ seen.add(p)
107
+ unique.append(p)
108
+ return unique
109
+
110
+
111
+ def run_zip(root: Path, outfile: Path, files: Sequence[Path], compression: int, logger: logging.Logger) -> int:
112
+ """
113
+ Invoke the system `zip` command to create/update the archive.
114
+ """
115
+ if not _zip_available():
116
+ logger.error("zip executable not found in PATH.")
117
+ return 127
118
+
119
+ flags = [f"-{compression}", "-q", "-X", "-y"]
120
+ cmd = ["zip", *flags, str(outfile)]
121
+ rel_files = [str(p.relative_to(root)) for p in files]
122
+
123
+ logger.debug("Executing: %s", " ".join(cmd + rel_files))
124
+ proc = subprocess.run(cmd + rel_files, cwd=str(root), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
125
+ if proc.returncode == 12:
126
+ logger.warning("zip reported: nothing to do (no files matched).")
127
+ return 12
128
+ if proc.returncode != 0:
129
+ logger.error("zip failed with exit code %d. Stderr:\n%s", proc.returncode, proc.stderr)
130
+ return proc.returncode
131
+
132
+
133
+ def build_arg_parser() -> argparse.ArgumentParser:
134
+ """
135
+ Build the CLI parser. CLI overrides config. Progress bar stays on stdout.
136
+ """
137
+ p = argparse.ArgumentParser(prog="zipwrap", description="Config-driven wrapper around Linux `zip` with tqdm.")
138
+ p.add_argument("-c", "--config", type=str, help="Path to config JSON.")
139
+ p.add_argument("--root", type=str, help="Root directory to zip from (default: config or .).")
140
+ p.add_argument("--include", action="append", help="Glob pattern(s) to include. Repeatable. If absent, uses config or '*'.")
141
+ p.add_argument("--exclude", action="append", help="Glob pattern(s) to exclude. Repeatable.")
142
+ p.add_argument("--outfile", type=str, help="Output archive path. Relative paths resolve under --root.")
143
+ group = p.add_mutually_exclusive_group()
144
+ group.add_argument("--recurse", action="store_true", default=None, help="Recurse into subdirectories while matching.")
145
+ group.add_argument("--no-recurse", action="store_true", default=None, help="Do not recurse into subdirectories while matching.")
146
+ p.add_argument("--compression", type=int, choices=range(0, 10), metavar="{0..9}", help="Compression level (0..9).")
147
+ p.add_argument("--verbose", action="store_true", help="Enable debug logging to stderr.")
148
+ p.add_argument("--log-file", type=str, help="Optional path to a logfile (stderr otherwise).")
149
+ return p
150
+
151
+
152
+ def configure_logging(verbose: bool, log_file: str | None) -> logging.Logger:
153
+ """
154
+ Set up logging on stderr or a file. Progress bar owns stdout exclusively.
155
+ """
156
+ logger = logging.getLogger("zipwrap")
157
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
158
+ handler: logging.Handler
159
+ if log_file:
160
+ handler = logging.FileHandler(log_file, encoding="utf-8")
161
+ else:
162
+ handler = logging.StreamHandler(sys.stderr)
163
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
164
+ logger.handlers.clear()
165
+ logger.addHandler(handler)
166
+ return logger
167
+
168
+
169
+ def main(argv: Sequence[str] | None = None) -> int:
170
+ """
171
+ Entrypoint. Parse args, build config, collect files with a progress bar,
172
+ and shell out to `zip`.
173
+ """
174
+ parser = build_arg_parser()
175
+ args = parser.parse_args(argv)
176
+
177
+ logger = configure_logging(verbose=args.verbose, log_file=args.log_file)
178
+
179
+ config_path = Path(args.config).resolve() if args.config else None
180
+ config = Config.from_sources(args, config_path)
181
+
182
+ logger.info("Root: %s", config.root)
183
+ logger.info("Outfile: %s", config.outfile)
184
+ logger.debug("Include: %s", ", ".join(config.include))
185
+ logger.debug("Exclude: %s", ", ".join(config.exclude))
186
+ logger.info("Recurse: %s | Compression: %d", config.recurse, config.compression)
187
+
188
+ # Stage 1: Include pass
189
+ stage1 = _glob_many(config.root, config.include, config.recurse)
190
+ with tqdm(total=len(stage1), desc="Scanning includes", unit="file", leave=False) as bar:
191
+ for _ in stage1:
192
+ bar.update(1)
193
+
194
+ # Stage 2: Exclusion filter
195
+ final_files = collect_files(config.root, config.include, config.exclude, config.recurse, outfile=config.outfile)
196
+ with tqdm(total=len(final_files), desc="Finalizing list", unit="file", leave=False) as bar:
197
+ for _ in final_files:
198
+ bar.update(1)
199
+
200
+ if not final_files:
201
+ logger.warning("No files matched after filters; nothing to zip.")
202
+ return 12
203
+
204
+ rc = run_zip(config.root, config.outfile, final_files, config.compression, logger)
205
+ if rc == 0:
206
+ logger.info("Wrote archive: %s (%d files)", config.outfile, len(final_files))
207
+ return rc
208
+
209
+
210
+ if __name__ == "__main__":
211
+ raise SystemExit(main())
zipwrap/config.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import argparse
6
+ import json
7
+ from typing import Iterable, Sequence
8
+
9
+ from zipwrap import DEFAULTS
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Config:
14
+ """Runtime configuration for zipwrap."""
15
+
16
+ root: Path
17
+ include: tuple[str, ...]
18
+ exclude: tuple[str, ...]
19
+ outfile: Path
20
+ recurse: bool
21
+ compression: int
22
+
23
+ @staticmethod
24
+ def from_sources(args: argparse.Namespace, config_path: Path | None) -> "Config":
25
+ """
26
+ Build final config by layering JSON (if present) under CLI flags.
27
+ CLI always wins. Missing values fall back to DEFAULTS.
28
+ """
29
+ data = dict(DEFAULTS)
30
+ if config_path:
31
+ with config_path.open("r", encoding="utf-8") as fh:
32
+ loaded = json.load(fh)
33
+ if not isinstance(loaded, dict):
34
+ raise ValueError("Config JSON must be an object at the top level.")
35
+ data.update({k: v for k, v in loaded.items() if v is not None})
36
+
37
+ def normalize_list(maybe_list, default: Sequence[str]) -> tuple[str, ...]:
38
+ if maybe_list is None:
39
+ return tuple(default)
40
+ if isinstance(maybe_list, str):
41
+ return (maybe_list,)
42
+ if isinstance(maybe_list, Iterable):
43
+ return tuple(str(x) for x in maybe_list)
44
+ raise TypeError("List-like expected for include/exclude")
45
+
46
+ # CLI overrides
47
+ root = Path(args.root if args.root is not None else data.get("root", DEFAULTS["root"])).resolve()
48
+ include = normalize_list(args.include if args.include else data.get("include", DEFAULTS["include"]), DEFAULTS["include"])
49
+ exclude = normalize_list(args.exclude if args.exclude else data.get("exclude", DEFAULTS["exclude"]), DEFAULTS["exclude"])
50
+ outfile = Path(args.outfile if args.outfile is not None else data.get("outfile", DEFAULTS["outfile"]))
51
+
52
+ if getattr(args, "recurse", None) is True:
53
+ recurse = True
54
+ elif getattr(args, "no_recurse", None) is True:
55
+ recurse = False
56
+ else:
57
+ recurse = bool(data.get("recurse", DEFAULTS["recurse"]))
58
+
59
+ compression = int(args.compression if args.compression is not None else data.get("compression", DEFAULTS["compression"]))
60
+ compression = max(0, min(9, compression))
61
+
62
+ if not outfile.is_absolute():
63
+ outfile = (root / outfile).resolve()
64
+
65
+ if not outfile.parent.exists():
66
+ outfile.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ return Config(root=root, include=include, exclude=exclude, outfile=outfile, recurse=recurse, compression=compression)
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.3
2
+ Name: zipwrap
3
+ Version: 1.0.2
4
+ Summary: A config-driven wrapper around the Linux `zip` tool
5
+ License: MIT
6
+ Author: DJ Stomp
7
+ Author-email: 85457381+DJStompZone@users.noreply.github.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: tqdm (>=4.67.1,<5.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Zipwrap
19
+
20
+ A config-driven wrapper around the Linux `zip` tool
21
+
22
+ ## Usage
23
+
24
+ ```sh
25
+ zipwrap --config config.json
26
+ ```
27
+
28
+ ```sh
29
+ zipwrap --root . --include "*.py" --exclude "venv/**" --outfile dist/code.zip --recurse --compression 9
30
+ ```
31
+
@@ -0,0 +1,8 @@
1
+ zipwrap/__init__.py,sha256=52ZdBExP_8FMjhEu1hJFJlc2SquUo2KsxTPgmUUURAI,423
2
+ zipwrap/__main__.py,sha256=bSyCz-PbBxNncF6m1daG0HvIBnGixM8Zz1zyw22It0Q,68
3
+ zipwrap/cli.py,sha256=3k2laG6ht6aNFOSazRoOd2SS6lFzZugbQlD4jsM_Www,7847
4
+ zipwrap/config.py,sha256=SvjBOh1l9gqFWzL4mUguRy8GbBk4RnePZM7YTp4krWw,2696
5
+ zipwrap-1.0.2.dist-info/METADATA,sha256=sEbm0hkFMx0rXOJcTRMIWVqXLIts9r7XJZDiNPmaGdE,842
6
+ zipwrap-1.0.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
7
+ zipwrap-1.0.2.dist-info/entry_points.txt,sha256=jBLVq9sg77KnWs9I55EN6L59UXJiHSJv-FXjBw1BHIc,44
8
+ zipwrap-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.0.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ zipwrap=zipwrap.cli:main
3
+