ecompress 2.0.0__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.
ecompress/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Compress any file below a target size in MB, with one simple command.
2
+
3
+ ecompress "video.mp4" 50
4
+
5
+ The target is a hard ceiling: the produced file is measured on disk and
6
+ re-parsed by an independent reader before the run is called a success.
7
+
8
+ Python usage::
9
+
10
+ from ecompress import compress
11
+
12
+ result = compress(r"D:\\Videos\\movie.mp4", 50)
13
+ print(result.output_path)
14
+ print(result.output_size_mb) # always < 50
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ecompress.api import compress
20
+ from ecompress.errors import (
21
+ CompressError,
22
+ InputFileError,
23
+ InvalidTargetError,
24
+ MissingDependencyError,
25
+ OutputValidationError,
26
+ TargetNotAchievableError,
27
+ ToolExecutionError,
28
+ UnsupportedFormatError,
29
+ )
30
+ from ecompress.reporting import ConsoleReporter, NullReporter, Reporter
31
+ from ecompress.result import Attempt, CompressionResult, MediaType
32
+ from ecompress.units import BYTES_PER_MB, bytes_to_mb, format_size, mb_to_bytes
33
+
34
+ __version__ = "2.0.0"
35
+
36
+ __all__ = [
37
+ "BYTES_PER_MB",
38
+ "Attempt",
39
+ "CompressError",
40
+ "CompressionResult",
41
+ "ConsoleReporter",
42
+ "InputFileError",
43
+ "InvalidTargetError",
44
+ "MediaType",
45
+ "MissingDependencyError",
46
+ "NullReporter",
47
+ "OutputValidationError",
48
+ "Reporter",
49
+ "TargetNotAchievableError",
50
+ "ToolExecutionError",
51
+ "UnsupportedFormatError",
52
+ "__version__",
53
+ "bytes_to_mb",
54
+ "compress",
55
+ "format_size",
56
+ "mb_to_bytes",
57
+ ]
ecompress/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m ecompress "file.mp4" 50``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ecompress.cli import main
6
+
7
+ __all__ = ["main"]
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main())
ecompress/api.py ADDED
@@ -0,0 +1,332 @@
1
+ """The public :func:`compress` entry point.
2
+
3
+ Orchestration order:
4
+
5
+ 1. Validate the input path and the requested target.
6
+ 2. Short-circuit when the file is already small enough.
7
+ 3. Detect the media type from content (falling back to the extension).
8
+ 4. Run the matching backend inside a private scratch directory.
9
+ 5. Re-validate the winning candidate and move it to its final name.
10
+
11
+ The returned :class:`~compress.result.CompressionResult` always satisfies
12
+ ``output_size_bytes < target_size_bytes``. When that cannot be achieved a
13
+ :class:`~compress.errors.TargetNotAchievableError` is raised - a result object
14
+ is never used to report a missed target.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import shutil
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from ecompress.backends.audio import AudioBackend
25
+ from ecompress.backends.base import Backend, BackendOutcome, Job
26
+ from ecompress.backends.image import ImageBackend
27
+ from ecompress.backends.pdf import PdfBackend
28
+ from ecompress.backends.video import VideoBackend
29
+ from ecompress.detect import detect_media_type
30
+ from ecompress.errors import (
31
+ InputFileError,
32
+ InvalidTargetError,
33
+ OutputValidationError,
34
+ TargetNotAchievableError,
35
+ )
36
+ from ecompress.naming import ReservedPath, reserve_output_path
37
+ from ecompress.reporting import NullReporter, Reporter
38
+ from ecompress.result import CompressionResult, MediaType
39
+ from ecompress.units import SizeRange, format_size, parse_size_range
40
+ from ecompress.validation import validate_output
41
+
42
+ __all__ = ["compress"]
43
+
44
+ #: Aim this far below the ceiling so ordinary variation cannot cross it.
45
+ #: 3% of the target, capped at 2 MB - for a 50 MB request we aim at ~48.5 MB.
46
+ SAFETY_MARGIN_RATIO = 0.03
47
+ SAFETY_MARGIN_CAP = 2_000_000
48
+
49
+ _BACKENDS = {
50
+ MediaType.IMAGE: ImageBackend,
51
+ MediaType.VIDEO: VideoBackend,
52
+ MediaType.AUDIO: AudioBackend,
53
+ MediaType.PDF: PdfBackend,
54
+ }
55
+
56
+
57
+ def compress(
58
+ path: str | Path,
59
+ target_mb: int | float | str | tuple[float, float] | list[float],
60
+ *,
61
+ min_mb: int | float | str | None = None,
62
+ output_path: str | Path | None = None,
63
+ reporter: Reporter | None = None,
64
+ overwrite: bool = False,
65
+ timeout: float | None = None,
66
+ ) -> CompressionResult:
67
+ """Compress ``path`` so the result is strictly smaller than ``target_mb``.
68
+
69
+ Args:
70
+ path: the file to compress. It is never modified or overwritten.
71
+ target_mb: the maximum size of the output in decimal megabytes
72
+ (``1 MB == 1_000_000 bytes``). Fractional values are allowed. A
73
+ range may be given instead - ``"40-50"``, ``"[40,50]"`` or
74
+ ``(40, 50)`` - meaning "below 50 MB but not below 40 MB".
75
+ min_mb: a quality floor, as an alternative to passing a range. The
76
+ search keeps raising quality until the output reaches it, so the
77
+ budget is used rather than undershot. It is a goal, not a hard
78
+ constraint: when even maximum quality lands below it (the source
79
+ is simply small, and inflating it would add bytes without adding
80
+ quality) the result is still returned, with an explanatory note.
81
+ output_path: write here instead of the automatic
82
+ ``<name>_compressed<ext>`` next to the input.
83
+ reporter: receives progress events; defaults to silence.
84
+ overwrite: allow ``output_path`` to replace an existing file.
85
+ timeout: seconds allowed for each individual encoder invocation.
86
+
87
+ Returns:
88
+ A :class:`~compress.result.CompressionResult` whose
89
+ ``output_size_bytes`` is strictly below ``target_size_bytes``. When the
90
+ input was already small enough, ``skipped`` is ``True`` and
91
+ ``output_path`` is the untouched original.
92
+
93
+ Raises:
94
+ InputFileError: the input is missing, empty, unreadable or a directory.
95
+ InvalidTargetError: ``target_mb`` is not a positive, finite number.
96
+ UnsupportedFormatError: the file type has no backend.
97
+ MissingDependencyError: FFmpeg is needed but not installed.
98
+ TargetNotAchievableError: no valid output could be brought under the
99
+ target.
100
+ """
101
+ reporter = reporter or NullReporter()
102
+ input_path = _validate_input(path)
103
+ size_range = _validate_target(target_mb, min_mb)
104
+ target_bytes = size_range.maximum
105
+ input_size = input_path.stat().st_size
106
+
107
+ reporter.step(f"Original size: {format_size(input_size)}")
108
+ if size_range.minimum is None:
109
+ reporter.step(f"Target size: {format_size(target_bytes)}")
110
+ else:
111
+ reporter.step(
112
+ f"Target size: {format_size(size_range.minimum)} to {format_size(target_bytes)}"
113
+ )
114
+ reporter.step("")
115
+
116
+ # Check the file type before the shortcut below, so an unsupported file is
117
+ # rejected rather than being waved through as "already small enough".
118
+ # ``allow_probe=False`` keeps this step free of any FFmpeg dependency.
119
+ if input_size < target_bytes:
120
+ notes = ["The file is already below the requested target; it was left untouched."]
121
+ if size_range.minimum is not None and input_size < size_range.minimum:
122
+ notes.append(
123
+ f"It is also below the {format_size(size_range.minimum)} minimum. "
124
+ "Padding it out would add bytes without adding quality, so it was "
125
+ "left as it is."
126
+ )
127
+ return CompressionResult(
128
+ input_path=input_path,
129
+ output_path=input_path,
130
+ input_size_bytes=input_size,
131
+ output_size_bytes=input_size,
132
+ target_size_bytes=target_bytes,
133
+ min_size_bytes=size_range.minimum,
134
+ media_type=detect_media_type(input_path, allow_probe=False).media_type,
135
+ attempts=[],
136
+ target_achieved=True,
137
+ skipped=True,
138
+ backend="none",
139
+ notes=notes,
140
+ )
141
+
142
+ reporter.step("Detecting media type...")
143
+ detection = detect_media_type(input_path)
144
+ for note in detection.notes:
145
+ reporter.note(note)
146
+
147
+ backend: Backend = _BACKENDS[detection.media_type]()
148
+ aim_bytes = _aim_bytes(target_bytes)
149
+
150
+ workdir, cleanup_root = _make_workdir(input_path, output_path)
151
+ try:
152
+ job = Job(
153
+ input_path=input_path,
154
+ input_size_bytes=input_size,
155
+ target_bytes=target_bytes,
156
+ aim_bytes=aim_bytes,
157
+ detection=detection,
158
+ workdir=workdir,
159
+ reporter=reporter,
160
+ timeout=timeout,
161
+ min_bytes=size_range.minimum,
162
+ )
163
+ outcome = backend.compress(job)
164
+
165
+ if not outcome.achieved or outcome.best_path is None:
166
+ raise TargetNotAchievableError(
167
+ input_path=input_path,
168
+ target_bytes=target_bytes,
169
+ smallest_valid_bytes=outcome.smallest_valid_bytes,
170
+ attempts=len(outcome.attempts),
171
+ detail=outcome.detail,
172
+ )
173
+
174
+ final = _finalise(
175
+ input_path=input_path,
176
+ outcome=outcome,
177
+ detection_extension=detection.extension,
178
+ explicit_output=output_path,
179
+ overwrite=overwrite,
180
+ target_bytes=target_bytes,
181
+ media_type=detection.media_type,
182
+ )
183
+ final_size = final.stat().st_size
184
+ notes = list(outcome.notes)
185
+ if size_range.minimum is not None and final_size < size_range.minimum:
186
+ notes.append(
187
+ f"The result came out below the {format_size(size_range.minimum)} "
188
+ "minimum: this is the largest valid output the source and format "
189
+ "can produce, and padding it would add bytes without adding quality."
190
+ )
191
+ return CompressionResult(
192
+ input_path=input_path,
193
+ output_path=final,
194
+ input_size_bytes=input_size,
195
+ output_size_bytes=final_size,
196
+ target_size_bytes=target_bytes,
197
+ min_size_bytes=size_range.minimum,
198
+ media_type=detection.media_type,
199
+ attempts=list(outcome.attempts),
200
+ target_achieved=True,
201
+ skipped=False,
202
+ format_changed=outcome.format_changed,
203
+ backend=backend.name,
204
+ notes=notes,
205
+ )
206
+ finally:
207
+ shutil.rmtree(cleanup_root, ignore_errors=True)
208
+
209
+
210
+ # -- input validation ------------------------------------------------------
211
+
212
+
213
+ def _validate_input(path: str | Path) -> Path:
214
+ if isinstance(path, Path):
215
+ candidate = path
216
+ elif isinstance(path, str):
217
+ if not path.strip():
218
+ raise InputFileError("No input file was given.")
219
+ candidate = Path(path)
220
+ else:
221
+ raise InputFileError(f"Expected a file path, got {type(path).__name__}.")
222
+
223
+ candidate = candidate.expanduser()
224
+ try:
225
+ exists = candidate.exists()
226
+ except OSError as exc:
227
+ raise InputFileError(f"Could not access {candidate}: {exc}") from exc
228
+
229
+ if not exists:
230
+ raise InputFileError(f"File not found: {candidate}\n\nCheck the path and try again.")
231
+ if candidate.is_dir():
232
+ raise InputFileError(
233
+ f"{candidate} is a folder, not a file.\n\nGive the path to a single file."
234
+ )
235
+ if not candidate.is_file():
236
+ raise InputFileError(f"{candidate} is not a regular file.")
237
+
238
+ try:
239
+ size = candidate.stat().st_size
240
+ except OSError as exc:
241
+ raise InputFileError(f"Could not read {candidate}: {exc}") from exc
242
+ if size == 0:
243
+ raise InputFileError(f"{candidate} is empty; there is nothing to compress.")
244
+
245
+ try:
246
+ with candidate.open("rb") as handle:
247
+ handle.read(1)
248
+ except OSError as exc:
249
+ raise InputFileError(f"No permission to read {candidate}: {exc}") from exc
250
+
251
+ return candidate
252
+
253
+
254
+ def _validate_target(target_mb: object, min_mb: object = None) -> SizeRange:
255
+ # bool is an int subclass; reject it explicitly.
256
+ if isinstance(target_mb, bool) or isinstance(min_mb, bool):
257
+ raise InvalidTargetError("The target size must be a number of megabytes, e.g. 50.")
258
+ try:
259
+ return parse_size_range(target_mb, minimum=min_mb) # type: ignore[arg-type]
260
+ except ValueError as exc:
261
+ raise InvalidTargetError(
262
+ f"{exc}\n\nGive the target as a number of megabytes, for example:\n"
263
+ ' ecompress "video.mp4" 50 below 50 MB\n'
264
+ ' ecompress "video.mp4" 40-50 below 50 MB but not under 40 MB'
265
+ ) from exc
266
+
267
+
268
+ def _aim_bytes(target_bytes: int) -> int:
269
+ margin = min(int(target_bytes * SAFETY_MARGIN_RATIO), SAFETY_MARGIN_CAP)
270
+ return max(target_bytes - margin, 1)
271
+
272
+
273
+ # -- workspace and finalisation --------------------------------------------
274
+
275
+
276
+ def _make_workdir(input_path: Path, output_path: str | Path | None) -> tuple[Path, Path]:
277
+ """A private scratch directory, preferably on the destination volume.
278
+
279
+ Keeping candidates on the same volume as the final file turns the last step
280
+ into a rename instead of a multi-gigabyte copy.
281
+ """
282
+ preferred = Path(output_path).expanduser().parent if output_path else input_path.parent
283
+ for base in (preferred, None):
284
+ try:
285
+ root = Path(tempfile.mkdtemp(prefix=".compress-", dir=base))
286
+ except (OSError, ValueError):
287
+ continue
288
+ return root, root
289
+ raise InputFileError( # pragma: no cover - only if even the system temp fails
290
+ "Could not create a temporary working directory."
291
+ )
292
+
293
+
294
+ def _finalise(
295
+ *,
296
+ input_path: Path,
297
+ outcome: BackendOutcome,
298
+ detection_extension: str,
299
+ explicit_output: str | Path | None,
300
+ overwrite: bool,
301
+ target_bytes: int,
302
+ media_type: MediaType,
303
+ ) -> Path:
304
+ """Re-check the winner, then move it into place under a unique name."""
305
+ best = outcome.best_path
306
+ assert best is not None # noqa: S101 - guarded by the caller
307
+
308
+ extension = outcome.output_extension or detection_extension
309
+ reserved: ReservedPath = reserve_output_path(
310
+ input_path,
311
+ extension=extension,
312
+ explicit=Path(explicit_output) if explicit_output is not None else None,
313
+ overwrite=overwrite,
314
+ )
315
+ try:
316
+ shutil.move(str(best), str(reserved.path))
317
+ except OSError as exc:
318
+ reserved.release()
319
+ raise InputFileError(f"Could not write the output file: {exc}") from exc
320
+
321
+ # Final independent check on the file that will actually be handed over.
322
+ final_size = reserved.path.stat().st_size
323
+ report = validate_output(reserved.path, media_type)
324
+ if not report.valid or final_size >= target_bytes or final_size == 0:
325
+ reason = report.reason if not report.valid else f"final size {format_size(final_size)}"
326
+ with contextlib.suppress(OSError):
327
+ reserved.path.unlink()
328
+ raise OutputValidationError(
329
+ "The compressed file failed its final check and was discarded "
330
+ f"({reason}). Nothing was written."
331
+ )
332
+ return reserved.path
@@ -0,0 +1,19 @@
1
+ """Compression backends, one per media family."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ecompress.backends.audio import AudioBackend
6
+ from ecompress.backends.base import Backend, BackendOutcome, Job
7
+ from ecompress.backends.image import ImageBackend
8
+ from ecompress.backends.pdf import PdfBackend
9
+ from ecompress.backends.video import VideoBackend
10
+
11
+ __all__ = [
12
+ "AudioBackend",
13
+ "Backend",
14
+ "BackendOutcome",
15
+ "ImageBackend",
16
+ "Job",
17
+ "PdfBackend",
18
+ "VideoBackend",
19
+ ]