pagonic 0.5.0__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 (79) hide show
  1. pagonic-0.5.0/LICENSE +21 -0
  2. pagonic-0.5.0/PKG-INFO +245 -0
  3. pagonic-0.5.0/Pagonic/__init__.py +62 -0
  4. pagonic-0.5.0/Pagonic/cli/__init__.py +16 -0
  5. pagonic-0.5.0/Pagonic/cli/commands/__init__.py +5 -0
  6. pagonic-0.5.0/Pagonic/cli/commands/benchmark.py +280 -0
  7. pagonic-0.5.0/Pagonic/cli/commands/config.py +216 -0
  8. pagonic-0.5.0/Pagonic/cli/main.py +752 -0
  9. pagonic-0.5.0/Pagonic/cli/policy.py +137 -0
  10. pagonic-0.5.0/Pagonic/cli/utils.py +144 -0
  11. pagonic-0.5.0/Pagonic/core/compression.py +165 -0
  12. pagonic-0.5.0/Pagonic/core/config_manager.py +287 -0
  13. pagonic-0.5.0/Pagonic/core/formats/__init__.py +21 -0
  14. pagonic-0.5.0/Pagonic/core/formats/adaptive_buffer.py +335 -0
  15. pagonic-0.5.0/Pagonic/core/formats/base.py +165 -0
  16. pagonic-0.5.0/Pagonic/core/formats/buffer_pool.py +355 -0
  17. pagonic-0.5.0/Pagonic/core/formats/compression_utils.py +552 -0
  18. pagonic-0.5.0/Pagonic/core/formats/constants.py +74 -0
  19. pagonic-0.5.0/Pagonic/core/formats/errors.py +262 -0
  20. pagonic-0.5.0/Pagonic/core/formats/handlers/__init__.py +99 -0
  21. pagonic-0.5.0/Pagonic/core/formats/handlers/zip_handler.py +301 -0
  22. pagonic-0.5.0/Pagonic/core/formats/hybrid_decompressor.py +219 -0
  23. pagonic-0.5.0/Pagonic/core/formats/inspection.py +629 -0
  24. pagonic-0.5.0/Pagonic/core/formats/minimal_zip_writer.py +467 -0
  25. pagonic-0.5.0/Pagonic/core/formats/optimized_decompressor.py +290 -0
  26. pagonic-0.5.0/Pagonic/core/formats/registry.py +790 -0
  27. pagonic-0.5.0/Pagonic/core/formats/results.py +62 -0
  28. pagonic-0.5.0/Pagonic/core/formats/security.py +276 -0
  29. pagonic-0.5.0/Pagonic/core/formats/simd_crc32.py +327 -0
  30. pagonic-0.5.0/Pagonic/core/formats/simd_memory.py +66 -0
  31. pagonic-0.5.0/Pagonic/core/formats/zip_parallel_orchestrator.py +129 -0
  32. pagonic-0.5.0/Pagonic/core/formats/zip_reader.py +479 -0
  33. pagonic-0.5.0/Pagonic/core/formats/zip_structs.py +698 -0
  34. pagonic-0.5.0/Pagonic/core/formats/zip_writer.py +304 -0
  35. pagonic-0.5.0/Pagonic/core/utils/__init__.py +13 -0
  36. pagonic-0.5.0/Pagonic/core/utils/path_utils.py +138 -0
  37. pagonic-0.5.0/Pagonic/gui/__init__.py +28 -0
  38. pagonic-0.5.0/Pagonic/gui/__main__.py +19 -0
  39. pagonic-0.5.0/Pagonic/gui/widgets/__init__.py +5 -0
  40. pagonic-0.5.0/Pagonic/gui/windows/__init__.py +12 -0
  41. pagonic-0.5.0/Pagonic/gui/windows/archive_window.py +402 -0
  42. pagonic-0.5.0/Pagonic/gui/windows/compress_dialog.py +400 -0
  43. pagonic-0.5.0/Pagonic/gui/windows/main_window.py +288 -0
  44. pagonic-0.5.0/Pagonic/gui/windows/settings_dialog.py +227 -0
  45. pagonic-0.5.0/Pagonic/gui/workers/__init__.py +10 -0
  46. pagonic-0.5.0/Pagonic/gui/workers/compression.py +118 -0
  47. pagonic-0.5.0/Pagonic/gui/workers/extraction.py +109 -0
  48. pagonic-0.5.0/README.md +212 -0
  49. pagonic-0.5.0/pagonic.egg-info/PKG-INFO +245 -0
  50. pagonic-0.5.0/pagonic.egg-info/SOURCES.txt +77 -0
  51. pagonic-0.5.0/pagonic.egg-info/dependency_links.txt +1 -0
  52. pagonic-0.5.0/pagonic.egg-info/entry_points.txt +3 -0
  53. pagonic-0.5.0/pagonic.egg-info/requires.txt +16 -0
  54. pagonic-0.5.0/pagonic.egg-info/top_level.txt +1 -0
  55. pagonic-0.5.0/pyproject.toml +69 -0
  56. pagonic-0.5.0/setup.cfg +4 -0
  57. pagonic-0.5.0/tests/test_api_contracts.py +85 -0
  58. pagonic-0.5.0/tests/test_callback_integration.py +266 -0
  59. pagonic-0.5.0/tests/test_ci_integration.py +107 -0
  60. pagonic-0.5.0/tests/test_cli.py +718 -0
  61. pagonic-0.5.0/tests/test_error_handling_comprehensive.py +291 -0
  62. pagonic-0.5.0/tests/test_examples.py +26 -0
  63. pagonic-0.5.0/tests/test_gui_entrypoint.py +27 -0
  64. pagonic-0.5.0/tests/test_hybrid_multiple_large_files.py +88 -0
  65. pagonic-0.5.0/tests/test_import_boundaries.py +116 -0
  66. pagonic-0.5.0/tests/test_inspection.py +408 -0
  67. pagonic-0.5.0/tests/test_inspection_schema.py +126 -0
  68. pagonic-0.5.0/tests/test_integration.py +317 -0
  69. pagonic-0.5.0/tests/test_package_audit.py +28 -0
  70. pagonic-0.5.0/tests/test_performance.py +79 -0
  71. pagonic-0.5.0/tests/test_policy.py +124 -0
  72. pagonic-0.5.0/tests/test_release_audit.py +35 -0
  73. pagonic-0.5.0/tests/test_sarif_evaluation.py +29 -0
  74. pagonic-0.5.0/tests/test_smart_compression.py +172 -0
  75. pagonic-0.5.0/tests/test_unicode_paths.py +161 -0
  76. pagonic-0.5.0/tests/test_zip64.py +135 -0
  77. pagonic-0.5.0/tests/test_zip_handler_compatibility.py +83 -0
  78. pagonic-0.5.0/tests/test_zip_reader_comprehensive.py +276 -0
  79. pagonic-0.5.0/tests/test_zip_writer_comprehensive.py +339 -0
pagonic-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pagonic contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pagonic-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: pagonic
3
+ Version: 0.5.0
4
+ Summary: A security-aware Python ZIP inspection and safe extraction toolkit.
5
+ Author: Pagonic contributors
6
+ License-Expression: MIT
7
+ Keywords: zip,archive,inspection,safe-extraction,cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
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
+ Classifier: Topic :: System :: Archiving :: Compression
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: click>=8.1.7
20
+ Requires-Dist: rich>=13.7.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2.0; extra == "dev"
23
+ Requires-Dist: coverage>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest>=8.3.5; extra == "dev"
25
+ Requires-Dist: pytest-cov>=6.1.1; extra == "dev"
26
+ Requires-Dist: psutil>=5.9.0; extra == "dev"
27
+ Provides-Extra: gui
28
+ Requires-Dist: PyQt6>=6.6.0; extra == "gui"
29
+ Provides-Extra: performance
30
+ Requires-Dist: numpy>=1.24.0; extra == "performance"
31
+ Requires-Dist: psutil>=5.9.0; extra == "performance"
32
+ Dynamic: license-file
33
+
34
+ # Pagonic
35
+
36
+ Pagonic is an alpha Python ZIP toolkit focused on safe archive inspection,
37
+ secure extraction, and repeatable local benchmarking. Its main idea is simple:
38
+
39
+ > Inspect before you extract.
40
+
41
+ <p align="center">
42
+ <img src="assets/pagonic-demo.gif" alt="Pagonic inspecting a suspicious ZIP and refusing unsafe automation" width="960">
43
+ </p>
44
+
45
+ The intended workflow is visible in the demo: inspect the archive, verify it
46
+ against an explicit risk threshold, and let `safe-extract` refuse unsafe input
47
+ before it writes files.
48
+
49
+ * A core library for inspecting, writing, reading, and validating ZIP archives.
50
+ * A `pagonic` command-line interface for inspect, verify, safe extract, and ZIP utilities.
51
+ * An optional PyQt6 GUI launched with `pagonic-gui`.
52
+
53
+ This repository contains the `v0.5.0` alpha release. The import package remains
54
+ `Pagonic` for compatibility; the distribution name is `pagonic`. No PyPI or
55
+ TestPyPI package is published, so install from a local checkout or use the
56
+ artifacts attached to the [GitHub release](https://github.com/SetraTheXX/pagonic/releases/tag/v0.5.0).
57
+
58
+ ## Project Story
59
+
60
+ Pagonic started more than a year ago as one of my earliest software-learning projects. Its first direction was much broader and more experimental: a ZIP/archive engine with compression, extraction, GUI ideas, benchmarking, and performance experiments.
61
+
62
+ After many iterations, I revised the project direction and narrowed the public scope into something clearer:
63
+
64
+ > Pagonic is not trying to be another desktop archive manager.
65
+ > It is becoming a security-aware ZIP inspection and safe extraction toolkit.
66
+
67
+ This `v0.5.0` release builds on the first cleaned-up public direction from
68
+ `v0.4.0`. It keeps the useful ZIP core, CLI, tests, and safety work while making
69
+ the inspection policy, regression corpus, automation examples, package surface,
70
+ and compatibility boundaries explicit.
71
+
72
+ Pagonic is still evolving, but its purpose is now clearer: inspect first, extract safely.
73
+
74
+ ## Install
75
+
76
+ No PyPI or TestPyPI package is published for this release, so install from a
77
+ local checkout after cloning the repository.
78
+
79
+ For CLI-only use from a local checkout:
80
+
81
+ ```bash
82
+ python -m pip install .
83
+ ```
84
+
85
+ For local development:
86
+
87
+ ```bash
88
+ python -m pip install -e .[dev,gui]
89
+ ```
90
+
91
+ For CLI-only development with test dependencies:
92
+
93
+ ```bash
94
+ python -m pip install -e .[dev]
95
+ ```
96
+
97
+ Experimental performance helpers are optional and are not required by inspection
98
+ or safe extraction:
99
+
100
+ ```bash
101
+ python -m pip install -e .[performance]
102
+ ```
103
+
104
+ The GUI is optional. If PyQt6 is not installed, `pagonic-gui` exits with a clear install message.
105
+
106
+ ## CLI Quick Start
107
+
108
+ ```bash
109
+ pagonic --help
110
+ pagonic inspect suspicious.zip
111
+ pagonic inspect suspicious.zip --json
112
+ pagonic inspect suspicious.zip --markdown
113
+ pagonic verify release.zip
114
+ pagonic verify release.zip --max-risk medium
115
+ pagonic safe-extract upload.zip output/
116
+ pagonic safe-extract upload.zip output/ --dry-run
117
+ pagonic list archive.zip --tree
118
+ pagonic compress path/to/file.txt -o archive.zip
119
+ pagonic config list
120
+ ```
121
+
122
+ Use `inspect` before extraction for untrusted ZIP files. `safe-extract` applies
123
+ the inspection gate before writing files, supports `--dry-run`, and refuses ZIP
124
+ entries that use unsupported compression methods.
125
+
126
+ ## Python API Quick Start
127
+
128
+ ```python
129
+ from Pagonic.core.formats.zip_writer import ZipWriter
130
+ from Pagonic.core.formats.zip_reader import ZipReader
131
+
132
+ writer = ZipWriter("archive.zip", compression_level=6)
133
+ writer.add_file("file.txt")
134
+ writer.finalize()
135
+
136
+ reader = ZipReader("archive.zip")
137
+ report = reader.inspect()
138
+
139
+ if report.risk_level in {"ok", "low"}:
140
+ reader.extract_all("output")
141
+ ```
142
+
143
+ ## Project Layout
144
+
145
+ ```text
146
+ Pagonic/ Python package
147
+ tests/ pytest suite
148
+ docs/ public documentation
149
+ examples/ small runnable examples
150
+ pyproject.toml package metadata and tool config
151
+ ```
152
+
153
+ ## Documentation
154
+
155
+ - [Architecture](docs/architecture.md)
156
+ - [User Guide](docs/user-guide.md)
157
+ - [Inspection Policy Contract](docs/inspection-policy.md)
158
+ - [Inspection JSON Schema Contract](docs/inspection-schema.md)
159
+ - [CI Integration](docs/ci-integration.md)
160
+ - [Package Surface Audit](docs/package-audit.md)
161
+ - [0.5 Migration Notes](docs/migration-0.5.md)
162
+ - [0.5 Release Audit](docs/release-audit-0.5.md)
163
+ - [SARIF Evaluation](docs/sarif-evaluation.md)
164
+ - [ZipHandler Compatibility Policy](docs/zip-handler-compatibility.md)
165
+ - [Developer Guide](docs/developer-guide.md)
166
+ - [0.4 Migration Notes](docs/migration-0.4.md)
167
+ - [Roadmap](docs/roadmap.md)
168
+ - [Changelog](CHANGELOG.md)
169
+ - [Contributing](CONTRIBUTING.md)
170
+ - [Security Policy](SECURITY.md)
171
+ - [Code of Conduct](CODE_OF_CONDUCT.md)
172
+
173
+ ## Contributing
174
+
175
+ Focused contributions are welcome, especially improvements to inspection
176
+ determinism, security regression coverage, safe extraction policy, CI
177
+ integration, and documentation. Read [CONTRIBUTING.md](CONTRIBUTING.md), check
178
+ [the 0.5 roadmap](docs/roadmap.md), and use the issue templates before opening
179
+ a pull request. Do not include private plans, local paths, secrets, generated
180
+ archives, or benchmark output in public changes.
181
+
182
+ ## Risk Signals
183
+
184
+ Inspection reports are deterministic and do not use runtime AI. Current risk
185
+ flags include:
186
+
187
+ | Flag | Severity | Meaning |
188
+ | -------------------------------- | ---------- | -------------------------------------------------------------------------------------- |
189
+ | `path_traversal` | `high` | Entry contains `..` path segments. |
190
+ | `absolute_path` | `high` | Entry uses a POSIX absolute path. |
191
+ | `windows_drive_path` | `high` | Entry looks like a Windows drive path. |
192
+ | `hidden_file` | `low` | Entry basename starts with `.`. |
193
+ | `empty_filename` | `medium` | Entry cannot be mapped to a useful safe path. |
194
+ | `too_many_files` | `high` | Archive exceeds the configured file-count limit. |
195
+ | `large_uncompressed_size` | `high` | Archive exceeds the configured uncompressed-size limit. |
196
+ | `high_compression_ratio` | `high` | Entry expands much more than its compressed size. |
197
+ | `unsupported_compression_method` | `medium` | Entry uses a ZIP method Pagonic does not currently support; `safe-extract` refuses it. |
198
+ | `crc_or_structure_error` | `critical` | ZIP structure or CRC validation failed. |
199
+ | `suspicious_extension` | `medium` | Entry has an executable or script-like extension. |
200
+ | `duplicate_filename` | `high` | The same archive filename appears more than once. |
201
+ | `normalized_path_collision` | `high` | Different names resolve to the same sanitized path. |
202
+ | `case_insensitive_collision` | `high` | Names collide on case-insensitive filesystems. |
203
+ | `unicode_normalization_collision`| `high` | Different Unicode spellings normalize to one path. |
204
+ | `symlink_entry` | `high` | ZIP metadata marks the entry as a symbolic link. |
205
+ | `encrypted_entry` | `high` | Entry contents cannot be validated by the current workflow. |
206
+ | `nested_archive` | `low` | Entry appears to contain another archive; it is not recursively inspected. |
207
+ | `long_filename` | `medium` | Entry name exceeds the configured review length. |
208
+ | `long_archive_comment` | `low` | Archive comment exceeds the configured review length. |
209
+
210
+ `pagonic inspect --json` emits a stable alpha report with archive totals,
211
+ overall `risk_level`, top-level `risk_flags`, `recommended_action`, and per-entry
212
+ metadata. Entries preserve archive order and risk flags use a deterministic
213
+ catalog order. See the [Inspection JSON Schema Contract](docs/inspection-schema.md)
214
+ for canonical fields, compatibility aliases, ordering guarantees, and clean,
215
+ risky, and invalid report examples. `pagonic inspect --markdown` renders the
216
+ same inspection as a saved human-readable report.
217
+
218
+ ## Command Policy
219
+
220
+ Use `inspect` before extraction when the archive is untrusted. For automation,
221
+ `verify` returns exit code `0` only when the report is within `--max-risk` and
222
+ has no validation errors. `safe-extract` applies the same inspection gate before
223
+ writing files and supports `--dry-run`.
224
+
225
+ See the [inspection policy contract](docs/inspection-policy.md) for the exact
226
+ clean/risky/invalid decision table, defaults, unsupported-method rule, and exit
227
+ codes.
228
+
229
+ The older `extract` command remains as a compatibility command for trusted
230
+ archives. It uses secure path handling, but it is not an inspection policy gate;
231
+ use `safe-extract` for untrusted input. `list` and `info` are read-only display
232
+ commands and do not replace an inspection report.
233
+
234
+ ## Status
235
+
236
+ The current public release is `0.5.0`: an alpha-stage, test-backed release with
237
+ security-aware ZIP inspection, explicit policy gates, a synthetic security
238
+ regression corpus, gated safe extraction, core ZIP behavior, CLI support,
239
+ optional GUI packaging, MIT license, and CI-ready tests.
240
+
241
+ Pagonic is not intended for production-critical automation yet and is not
242
+ positioned as a general multi-format desktop archive manager. The next work is
243
+ evidence-driven maintenance: collect real usage signals, expand the security
244
+ corpus when new rules are added, and revisit deferred integrations only when a
245
+ concrete consumer justifies them.
@@ -0,0 +1,62 @@
1
+ """Pagonic security-aware ZIP inspection and extraction toolkit."""
2
+
3
+ __version__ = "0.5.0"
4
+ __author__ = "Pagonic contributors"
5
+
6
+ from .core.formats.base import FormatHandler
7
+
8
+ __all__ = [
9
+ "FormatHandler",
10
+ "ZipHandler",
11
+ "ZipReader",
12
+ "ZipWriter",
13
+ "inspect_archive",
14
+ "ArchiveEntryReport",
15
+ "ArchiveInspectionReport",
16
+ "ArchiveRisk",
17
+ "ArchiveInfo",
18
+ "CompressionStats",
19
+ "ExtractionFailure",
20
+ "ExtractionResult",
21
+ "FileInfo",
22
+ "RiskDefinition",
23
+ "RISK_CATALOG",
24
+ "get_risk_definition",
25
+ "__version__",
26
+ ]
27
+
28
+
29
+ _LAZY_EXPORTS = {
30
+ "ZipHandler": ("Pagonic.core.formats.handlers.zip_handler", "ZipHandler"),
31
+ "ZipReader": ("Pagonic.core.formats.zip_reader", "ZipReader"),
32
+ "ZipWriter": ("Pagonic.core.formats.zip_writer", "ZipWriter"),
33
+ "inspect_archive": ("Pagonic.core.formats.inspection", "inspect_archive"),
34
+ "ArchiveEntryReport": ("Pagonic.core.formats.inspection", "ArchiveEntryReport"),
35
+ "ArchiveInspectionReport": (
36
+ "Pagonic.core.formats.inspection",
37
+ "ArchiveInspectionReport",
38
+ ),
39
+ "ArchiveRisk": ("Pagonic.core.formats.inspection", "ArchiveRisk"),
40
+ "ArchiveInfo": ("Pagonic.core.formats.results", "ArchiveInfo"),
41
+ "CompressionStats": ("Pagonic.core.formats.results", "CompressionStats"),
42
+ "ExtractionFailure": ("Pagonic.core.formats.results", "ExtractionFailure"),
43
+ "ExtractionResult": ("Pagonic.core.formats.results", "ExtractionResult"),
44
+ "FileInfo": ("Pagonic.core.formats.results", "FileInfo"),
45
+ "RiskDefinition": ("Pagonic.core.formats.inspection", "RiskDefinition"),
46
+ "RISK_CATALOG": ("Pagonic.core.formats.inspection", "RISK_CATALOG"),
47
+ "get_risk_definition": ("Pagonic.core.formats.inspection", "get_risk_definition"),
48
+ }
49
+
50
+
51
+ def __getattr__(name):
52
+ """Load public ZIP APIs only when a caller requests them."""
53
+ target = _LAZY_EXPORTS.get(name)
54
+ if target is None:
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
56
+
57
+ import importlib
58
+
59
+ module_name, attribute_name = target
60
+ value = getattr(importlib.import_module(module_name), attribute_name)
61
+ globals()[name] = value
62
+ return value
@@ -0,0 +1,16 @@
1
+ """Command-line interface for Pagonic."""
2
+
3
+ from Pagonic import __version__
4
+
5
+ __all__ = ["cli"]
6
+
7
+
8
+ def __getattr__(name):
9
+ """Load the Click application only when the compatibility export is used."""
10
+ if name != "cli":
11
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
12
+
13
+ from .main import cli
14
+
15
+ globals()[name] = cli
16
+ return cli
@@ -0,0 +1,5 @@
1
+ """
2
+ Pagonic CLI Commands Package
3
+ ============================
4
+ Individual command implementations for the CLI.
5
+ """
@@ -0,0 +1,280 @@
1
+ """
2
+ Pagonic CLI - Benchmark Command
3
+ ================================
4
+ Local benchmark runner for Pagonic ZIP workflows.
5
+
6
+ Benchmark output is intended for local validation and regression tracking, not
7
+ as a universal performance claim.
8
+ """
9
+
10
+ import os
11
+ import time
12
+ import tempfile
13
+ import random
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich.panel import Panel
20
+ from rich import box
21
+
22
+ from Pagonic.cli.utils import format_size, format_time
23
+
24
+ console = Console()
25
+
26
+
27
+ def generate_test_data(size_mb: int) -> bytes:
28
+ """Generate compressible test data."""
29
+ # Mix of random and repetitive data (realistic compression scenario)
30
+ chunk_size = 1024 * 1024 # 1 MB chunks
31
+ data = bytearray()
32
+
33
+ for i in range(size_mb):
34
+ if i % 3 == 0:
35
+ # Highly compressible: repeated text
36
+ text = "The quick brown fox jumps over the lazy dog. " * 23000
37
+ data.extend(text.encode('utf-8')[:chunk_size])
38
+ elif i % 3 == 1:
39
+ # Medium compressible: code-like
40
+ lines = []
41
+ for j in range(5000):
42
+ lines.append(f"def function_{j}(x, y): return x + y * {j}")
43
+ code = "\n".join(lines)
44
+ data.extend(code.encode('utf-8')[:chunk_size])
45
+ else:
46
+ # Low compressible: random data
47
+ random_bytes = bytes([random.randint(0, 255) for _ in range(chunk_size)])
48
+ data.extend(random_bytes)
49
+
50
+ return bytes(data)
51
+
52
+
53
+ def run_compression_benchmark(size_mb: int, level: int, iterations: int) -> dict:
54
+ """Run compression benchmark."""
55
+ from Pagonic.core.formats.zip_writer import ZipWriter
56
+
57
+ results = {
58
+ 'compress_times': [],
59
+ 'compress_speeds': [],
60
+ 'ratios': [],
61
+ 'output_sizes': []
62
+ }
63
+
64
+ # Generate test data once
65
+ console.print(f"[dim]Generating {size_mb} MB test data...[/]")
66
+ test_data = generate_test_data(size_mb)
67
+ original_size = len(test_data)
68
+
69
+ with tempfile.TemporaryDirectory() as tmpdir:
70
+ # Create test file
71
+ test_file = Path(tmpdir) / "benchmark_data.bin"
72
+ test_file.write_bytes(test_data)
73
+
74
+ for i in range(iterations):
75
+ output_zip = Path(tmpdir) / f"benchmark_{i}.zip"
76
+
77
+ # Compress
78
+ start = time.perf_counter()
79
+ writer = ZipWriter(str(output_zip), compression_level=level)
80
+ writer.add_file(str(test_file))
81
+ writer.finalize()
82
+ end = time.perf_counter()
83
+
84
+ compress_time = end - start
85
+ output_size = output_zip.stat().st_size
86
+ compress_speed = original_size / compress_time / 1024 / 1024 # MB/s
87
+ ratio = (1 - output_size / original_size) * 100
88
+
89
+ results['compress_times'].append(compress_time)
90
+ results['compress_speeds'].append(compress_speed)
91
+ results['output_sizes'].append(output_size)
92
+ results['ratios'].append(ratio)
93
+
94
+ # Cleanup
95
+ output_zip.unlink()
96
+
97
+ # Calculate averages
98
+ results['avg_compress_time'] = sum(results['compress_times']) / len(results['compress_times'])
99
+ results['avg_compress_speed'] = sum(results['compress_speeds']) / len(results['compress_speeds'])
100
+ results['avg_ratio'] = sum(results['ratios']) / len(results['ratios'])
101
+ results['avg_output_size'] = sum(results['output_sizes']) / len(results['output_sizes'])
102
+ results['original_size'] = original_size
103
+
104
+ return results
105
+
106
+
107
+ def run_decompression_benchmark(size_mb: int, level: int, iterations: int) -> dict:
108
+ """Run decompression benchmark."""
109
+ from Pagonic.core.formats.zip_writer import ZipWriter
110
+ from Pagonic.core.formats.zip_reader import ZipReader
111
+
112
+ results = {
113
+ 'decompress_times': [],
114
+ 'decompress_speeds': []
115
+ }
116
+
117
+ # Generate and compress test data
118
+ console.print(f"[dim]Preparing compressed archive...[/]")
119
+ test_data = generate_test_data(size_mb)
120
+ original_size = len(test_data)
121
+
122
+ with tempfile.TemporaryDirectory() as tmpdir:
123
+ # Create test file and archive
124
+ test_file = Path(tmpdir) / "benchmark_data.bin"
125
+ test_file.write_bytes(test_data)
126
+
127
+ archive_path = Path(tmpdir) / "benchmark.zip"
128
+ writer = ZipWriter(str(archive_path), compression_level=level)
129
+ writer.add_file(str(test_file))
130
+ writer.finalize()
131
+
132
+ # Remove original file
133
+ test_file.unlink()
134
+
135
+ for i in range(iterations):
136
+ output_dir = Path(tmpdir) / f"output_{i}"
137
+ output_dir.mkdir()
138
+
139
+ # Decompress
140
+ start = time.perf_counter()
141
+ reader = ZipReader(str(archive_path))
142
+ reader.extract_all(str(output_dir))
143
+ end = time.perf_counter()
144
+
145
+ decompress_time = end - start
146
+ decompress_speed = original_size / decompress_time / 1024 / 1024 # MB/s
147
+
148
+ results['decompress_times'].append(decompress_time)
149
+ results['decompress_speeds'].append(decompress_speed)
150
+
151
+ # Cleanup
152
+ for f in output_dir.iterdir():
153
+ f.unlink()
154
+ output_dir.rmdir()
155
+
156
+ # Calculate averages
157
+ results['avg_decompress_time'] = sum(results['decompress_times']) / len(results['decompress_times'])
158
+ results['avg_decompress_speed'] = sum(results['decompress_speeds']) / len(results['decompress_speeds'])
159
+ results['original_size'] = original_size
160
+
161
+ return results
162
+
163
+
164
+ @click.command()
165
+ @click.option('--size', '-s', default=10, type=click.IntRange(1, 500),
166
+ help='Test data size in MB (1-500)')
167
+ @click.option('--level', '-l', default=6, type=click.IntRange(0, 9),
168
+ help='Compression level (0-9)')
169
+ @click.option('--iterations', '-i', default=3, type=click.IntRange(1, 10),
170
+ help='Number of benchmark iterations (1-10)')
171
+ @click.option('--compress-only', is_flag=True, help='Run only compression benchmark')
172
+ @click.option('--decompress-only', is_flag=True, help='Run only decompression benchmark')
173
+ def benchmark(size: int, level: int, iterations: int, compress_only: bool, decompress_only: bool):
174
+ """
175
+ Run a local benchmark.
176
+
177
+ Measures compression and decompression on generated test data for local
178
+ validation and regression tracking. Results are workload-specific and are
179
+ not universal performance claims.
180
+
181
+ \b
182
+ Examples:
183
+ pagonic benchmark # Default: 10MB, level 6, 3 iterations
184
+ pagonic benchmark -s 50 -l 9 # 50MB with max compression
185
+ pagonic benchmark -s 100 -i 5 # 100MB with 5 iterations
186
+ pagonic benchmark --compress-only # Only test compression
187
+ """
188
+ console.print()
189
+ console.print(Panel.fit(
190
+ f"[bold magenta] Pagonic Local Benchmark[/]",
191
+ subtitle=f"Size: {size}MB | Level: {level} | Iterations: {iterations}",
192
+ border_style="magenta",
193
+ box=box.ASCII,
194
+ ))
195
+ console.print()
196
+
197
+ try:
198
+ # Compression benchmark
199
+ if not decompress_only:
200
+ console.print("[bold cyan] Compression Benchmark[/]")
201
+ console.print("[dim]Running compression tests...[/]")
202
+ compress_results = run_compression_benchmark(size, level, iterations)
203
+ console.print("[green]Compression tests complete.[/]")
204
+
205
+ # Decompression benchmark
206
+ if not compress_only:
207
+ console.print()
208
+ console.print("[bold green] Decompression Benchmark[/]")
209
+ console.print("[dim]Running decompression tests...[/]")
210
+ decompress_results = run_decompression_benchmark(size, level, iterations)
211
+ console.print("[green]Decompression tests complete.[/]")
212
+
213
+ # Results table
214
+ console.print()
215
+ table = Table(title=" Benchmark Results", box=box.ASCII)
216
+ table.add_column("Metric", style="cyan", no_wrap=True)
217
+ table.add_column("Value", style="green")
218
+ table.add_column("Notes", style="dim")
219
+
220
+ table.add_row(" Test Size", format_size(size * 1024 * 1024), f"{size} MB")
221
+ table.add_row(" Compression Level", str(level), "0=store, 9=more compression")
222
+ table.add_row(" Iterations", str(iterations), "")
223
+ table.add_row("", "", "") # Separator
224
+
225
+ if not decompress_only:
226
+ table.add_row(
227
+ " Compress Speed",
228
+ f"[bold]{compress_results['avg_compress_speed']:.1f} MB/s[/]",
229
+ f"{max(compress_results['compress_speeds']) - min(compress_results['compress_speeds']):.1f}"
230
+ )
231
+ table.add_row(
232
+ " Compression Ratio",
233
+ f"{compress_results['avg_ratio']:.1f}%",
234
+ f"{format_size(compress_results['avg_output_size'])} output"
235
+ )
236
+ table.add_row(
237
+ " Compress Time",
238
+ format_time(compress_results['avg_compress_time']),
239
+ f"per {size}MB"
240
+ )
241
+
242
+ if not compress_only:
243
+ if not decompress_only:
244
+ table.add_row("", "", "") # Separator
245
+ table.add_row(
246
+ " Decompress Speed",
247
+ f"[bold]{decompress_results['avg_decompress_speed']:.1f} MB/s[/]",
248
+ f"{max(decompress_results['decompress_speeds']) - min(decompress_results['decompress_speeds']):.1f}"
249
+ )
250
+ table.add_row(
251
+ " Decompress Time",
252
+ format_time(decompress_results['avg_decompress_time']),
253
+ f"per {size}MB"
254
+ )
255
+
256
+ console.print(table)
257
+
258
+ # Summary panel
259
+ console.print()
260
+ console.print(Panel.fit(
261
+ f"[bold] Local Benchmark Notes[/]\n\n"
262
+ f"Compression measured [bold green]{compress_results['avg_compress_speed']:.0f} MB/s[/]; "
263
+ f"decompression measured [bold green]{decompress_results['avg_decompress_speed']:.0f} MB/s[/] "
264
+ f"for this local benchmark run.\n"
265
+ f"Use these numbers for local validation and regression tracking only.",
266
+ border_style="green",
267
+ box=box.ASCII,
268
+ ) if not compress_only and not decompress_only else None)
269
+
270
+ console.print()
271
+ console.print("[bold green] Benchmark complete![/]")
272
+ console.print()
273
+
274
+ except Exception as e:
275
+ console.print(f"\n[red] Benchmark error:[/] {str(e)}")
276
+ raise SystemExit(1)
277
+
278
+
279
+ if __name__ == '__main__':
280
+ benchmark()