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 +20 -0
- zipwrap/__main__.py +4 -0
- zipwrap/cli.py +211 -0
- zipwrap/config.py +68 -0
- zipwrap-1.0.2.dist-info/METADATA +31 -0
- zipwrap-1.0.2.dist-info/RECORD +8 -0
- zipwrap-1.0.2.dist-info/WHEEL +4 -0
- zipwrap-1.0.2.dist-info/entry_points.txt +3 -0
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
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,,
|