Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
307cf36
test(cli): split test_cli.py into a package per command
rdawebb Oct 22, 2026
b611962
refactor(frontend): move the dispatch rules out of the CLI
rdawebb Oct 22, 2026
a8e8258
refactor(frontend): plan the compress and extract jobs in the frontend
rdawebb Oct 22, 2026
140190c
feat(frontend): infer an archive's format from its output name, defau…
rdawebb Oct 22, 2026
5aeb749
refactor(cli): move all CLI printing into _render
rdawebb Oct 22, 2026
8d66d5a
feat(cli): carry on past a failed input instead of stopping
rdawebb Oct 23, 2026
196febd
feat(cli): rename 'list' command to 'formats', and show the formats too
rdawebb Oct 23, 2026
856546a
perf(cli): defer the asyncio and tabulate imports
rdawebb Oct 23, 2026
1a70015
feat(cli): add a 'list' command for an archive's entries
rdawebb Oct 23, 2026
d1641ce
feat(cli): rename 'inspect' to 'info' and accept several inputs
rdawebb Oct 24, 2026
8759294
feat(cli): trim aliases and drop the 'comp' executable
rdawebb Oct 24, 2026
baa83cf
feat(cli): replace the overwrite flags with '--on-conflict' and '-f'
rdawebb Oct 24, 2026
dc600a7
feat(cli): print one summary line per input by default
rdawebb Oct 26, 2026
b13fe6d
fix(cli): refuse --algo and --strategy for formats other than '.comp'
rdawebb Oct 26, 2026
5d4a4c6
feat(cli): split extract's output into -C and -o
rdawebb Oct 27, 2026
3d7b5e6
feat(cli): add --member flag to extract only named entries
rdawebb Oct 27, 2026
dd09a47
feat(cli): expose the remaining extract options
rdawebb Oct 29, 2026
8616ef9
refactor(benchmark): make benchmark's flags repeatable, and move prin…
rdawebb Oct 29, 2026
149f1ec
fix(cli): drop the ratio on extraction and space the summaries evenly
rdawebb Oct 29, 2026
19bda28
feat(core): match entry paths against globs
rdawebb Oct 29, 2026
3dfdd12
feat(core): filter archive entries with include and exclude globs
rdawebb Oct 30, 2026
68acf7e
feat(cli): filter archive entries with --include and --exclude flags
rdawebb Oct 31, 2026
2e04079
feat: delete inputs once their output is written, with --rm flag
rdawebb Oct 31, 2026
e1474a7
feat: archive symlink target files, with --dereference
rdawebb Nov 1, 2026
b857d8d
feat: 'test' command to check archives and compressed files for damage
rdawebb Nov 1, 2026
78334cc
feat(info): report what a compressed file's own metadata states
rdawebb Nov 4, 2026
bf3b30b
feat(cli): validate -a, -s and -F against what the build supports
rdawebb Nov 5, 2026
0718eaa
refactor(cli): pad a run's output in one place
rdawebb Nov 5, 2026
96882ba
refactor(cli): add a decorator for command error handling
rdawebb Nov 5, 2026
1a052d6
fix(win32): stat through a link, and don't raise writing to a pipe
rdawebb Nov 6, 2026
d23280c
fix(fsutil): join paths with a platform-specific separator
rdawebb Nov 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions bench/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
from tabulate import tabulate

from compresso import _core
from compresso.introspect.benchmark import BenchmarkResult, print_results
from compresso.cli._render.benchmark import benchmark_results
from compresso.introspect.benchmark import BenchmarkResult

REPO_ROOT = Path(__file__).resolve().parent.parent
WORK_DIR = REPO_ROOT / "build" / "bench"
Expand Down Expand Up @@ -326,7 +327,7 @@ def main() -> None:
args = parser.parse_args()

results = run(args.formats.split(","), args.size_mb, args.repeats, args.cool)
print_results(results)
benchmark_results(results)

if args.compare:
compare(results, args.compare)
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ dev = [

[project.scripts]
compresso = "compresso.cli:main"
comp = "compresso.cli:main"

[tool.meson-python.args]
# Meson only activates MSVC when no other compiler is on PATH, which the MinGW
Expand Down
13 changes: 11 additions & 2 deletions src/compresso/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ExtractionPolicyError,
HeaderError,
IncompleteExtractionError,
SourceRemovalError,
TrailingDataWarning,
compress_file,
decompress_file,
Expand All @@ -20,8 +21,12 @@
CompressionPlan,
DecompressionJob,
DecompressionPlan,
Strategy,
VerificationJob,
VerificationPlan,
plan_compression,
plan_decompression,
plan_verification,
)
from .frontend.archive_api import (
ArchiveEntry,
Expand All @@ -36,7 +41,7 @@
plan_archive,
plan_extraction,
)
from .introspect.benchmark import benchmark_file, print_results
from .introspect.benchmark import benchmark_file
from .introspect.capabilities import list_capabilities
from .introspect.file_inspect import InspectResult, inspect
from .introspect.speeds import get_estimated_speeds
Expand Down Expand Up @@ -67,8 +72,12 @@
"JobResult",
"OverwriteMode",
"ProgressCallback",
"SourceRemovalError",
"Strategy",
"SymlinkMode",
"TrailingDataWarning",
"VerificationJob",
"VerificationPlan",
"benchmark_file",
"compress_file",
"decompress_file",
Expand All @@ -79,5 +88,5 @@
"plan_compression",
"plan_decompression",
"plan_extraction",
"print_results",
"plan_verification",
]
66 changes: 63 additions & 3 deletions src/compresso/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ class IncompleteExtractionError(Error):
Raises on completion, anything extracted is kept.
"""

class SourceRemovalError(Error):
"""An archive was written, but some of its sources could not be deleted.

The archive is complete, and every other source is deleted.

Attributes:
output: The path the archive was written to.
failures: Each path left behind, with the reason.
"""

output: str
failures: list[tuple[str, str]]

class CorruptDataError(Error):
"""The input is corrupt, truncated, or not the format it claims to be."""

Expand Down Expand Up @@ -153,14 +166,27 @@ def create_archive(
threads: int = ...,
progress: ProgressFn | None = ...,
cancel: CancelToken | None = ...,
include: Sequence[str] = ...,
exclude: Sequence[str] = ...,
remove_sources: bool = ...,
dereference: bool = ...,
) -> str:
"""Create an archive from the given input paths in the given format.

`overwrite` is 0 = error (default), 1 = skip, 2 = overwrite, 3 = rename;
applied to the single destination archive.
applied to the single destination archive; `remove_sources` with skip
raises ValueError.

Returns the path actually written, which RENAME may have changed from
`output_path`; SKIP returns `output_path` unchanged and writes nothing.

`include` and `exclude` are globs matched against entry names; an exclude
wins and isn't descended into, and with any includes, only matching entries
and the directories holding them are stored.

`dereference` stores what symlinks lead to; one that's dangling, or that
leads to a directory above it, stays a link; `remove_sources` then deletes
the links, and not their targets.
"""

def extract_archive(
Expand All @@ -179,6 +205,8 @@ def extract_archive(
threads: int = ...,
progress: ProgressFn | None = ...,
cancel: CancelToken | None = ...,
include: Sequence[str] = ...,
exclude: Sequence[str] = ...,
) -> None:
"""Extract an archive to output_dir; an empty `files` extracts every entry.

Expand All @@ -187,12 +215,31 @@ def extract_archive(

`overwrite` is 0 = error, 1 = skip, 2 = overwrite, 3 = rename;
`allow_symlinks` is 0 = deny, 1 = allow if the target stays inside
output_dir, 2 = rewrite to regular files, -1 = the format's default (2 for zip, 0 otherwise);
`max_total_size` and `max_depth` treat 0 as unlimited;
output_dir, 2 = rewrite to regular files, -1 = the format's default (2 for zip,
0 otherwise); `max_total_size` and `max_depth` treat 0 as unlimited;
`overwrite_dir_metadata` also restores the mode and mtime of a directory
that already exists, as GNU tar does, except when `overwrite` is 1 (skip);
`exact_permissions` restores modes verbatim, where by default
setuid/setgid/sticky are dropped and the umask applies.

`include` and `exclude` are matched as for `create_archive`: as in bsdtar,
an entry is extracted if `files` names it or an include matches it, and an
exclude overrides both; a directory entry is kept only if it matches
itself; an include that no entry matches raises `IncompleteExtractionError`,
as a name in `files` does, after the rest is extracted.
"""

def verify_file(
path: StrOrBytesPath,
*,
threads: int = ...,
progress: ProgressFn | None = ...,
cancel: CancelToken | None = ...,
) -> None:
"""Decode all of an archive or compressed file, writing nothing.

Raises what extracting it would for damaged data, so it proves only what
the format checks: a plain tar's file data carries no checksum.
"""

def detect_format(file_path: StrOrBytesPath) -> str:
Expand All @@ -204,9 +251,22 @@ def gzip_stored_name(file_path: StrOrBytesPath) -> bytes | None:
Always None for a non-gzip file.
"""

def stored_content_size(file_path: StrOrBytesPath) -> int | None:
"""Return the uncompressed size an xz, zstd, lz4 or snappy file states, or None.

Reads no compressed data, so a damaged file can still state a size; None for
any other format, a zstd or lz4 frame written from a stream, or trailing data.
"""

def format_is_archive(format: str) -> bool:
"""Return whether a format's container can hold more than one entry."""

def format_stores_hardlinks(format: str) -> bool:
"""Return whether an archive format stores a file's later names as links."""

def path_matches(path: str, patterns: Sequence[str]) -> bool:
"""Return whether any glob matches an entry path, as `include` and `exclude` do."""

class _StandaloneFormatDict(TypedDict):
"""One single file container format, as defined by `standalone_formats`."""

Expand Down
88 changes: 84 additions & 4 deletions src/compresso/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

from __future__ import annotations

from . import algos, benchmark, compress, extract, inspect
import io
import re
import sys
from typing import Any, TextIO

from . import benchmark, compress, extract, formats, info, verify
from . import list as list_
from ._app import app

__all__ = ["app", "main"]
Expand All @@ -11,18 +17,92 @@
_COMMAND_MODULES = (
compress,
extract,
inspect,
verify,
list_,
info,
formats,
benchmark,
algos,
)

for _module in _COMMAND_MODULES:
_module.register()


# Styling and spaces, so Rich's padded blank lines count as blank
_INVISIBLE = re.compile(r"\x1b\[[0-9;]*m| ")


class _Padded:
"""A terminal stream that gives a run's output one blank line above it.

main() adds the one below. Output that already starts or ends with a blank
line, as `--help` does, gets no extra.
"""

# Shared, so stdout and stderr pad once between them
tail: str | None = None # the last two visible characters written
last: _Padded | None = None

def __init__(self, stream: TextIO) -> None:
"""Initialise a padded stream.

Args:
stream: The underlying stream to write to.
"""
self._stream = stream

def write(self, data: str) -> int:
"""Write to the underlying stream, with a blank line above if needed.

Args:
data: The string to write.

Returns:
The number of characters written.
"""
# Typer/Click probes for a binary stream with write(b"")
visible = _INVISIBLE.sub("", data) if isinstance(data, str) else ""
if visible:
if _Padded.tail is None and not visible.startswith("\n"):
self._stream.write("\n")

_Padded.tail = ((_Padded.tail or "") + visible)[-2:]
_Padded.last = self

return self._stream.write(data)

def __getattr__(self, name: str) -> Any:
"""Delegate attribute access to the underlying stream.

Args:
name: The name of the attribute to access.

Returns:
The value of the requested attribute.
"""
return getattr(self._stream, name)


def main() -> None:
"""Entry point for the CLI."""
app()
# A pipe on Windows takes the locale code page, which can't encode markers
# like '→'; escape rather than raise
if isinstance(sys.stdout, io.TextIOWrapper):
sys.stdout.reconfigure(errors="backslashreplace")

# Not when piped, as tar and gzip don't pad what a script reads
if sys.stdout.isatty():
sys.stdout = _Padded(sys.stdout)

if sys.stderr.isatty():
sys.stderr = _Padded(sys.stderr)

try:
app()

finally:
if _Padded.last and _Padded.tail != "\n\n":
_Padded.last.write("\n")


if __name__ == "__main__":
Expand Down
Loading
Loading