Skip to content

Commit 3ef9d7d

Browse files
committed
feat(cli): add a simple cli
Implemented a new pybit7z command-line utility using argparse for argument parsing. The tool handles: - Archive creation with multiple format support (7z, zip, tar, etc.) - File extraction from compressed archives - Detailed archive content listing - Custom compression levels (0-9) - Password protection for encrypted archives Signed-off-by: msclock <[email protected]>
1 parent fd1a996 commit 3ef9d7d

File tree

5 files changed

+343
-30
lines changed

5 files changed

+343
-30
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ dependencies = [
3232
"importlib_metadata>=1.4",
3333
]
3434

35+
[project.scripts]
36+
pybit7z = "pybit7z.cli:main"
37+
3538
[project.optional-dependencies]
3639
test = ["pytest >=6", "pytest-cov >=3"]
3740
dev = ["pytest >=6", "pytest-cov >=3"]

src/pybit7z/cli.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
from pathlib import Path
6+
7+
from . import (
8+
Bit7zLibrary,
9+
BitArchiveReader,
10+
BitArchiveWriter,
11+
BitCompressionLevel,
12+
BitInOutFormat,
13+
FormatBZip2,
14+
FormatGZip,
15+
FormatSevenZip,
16+
FormatTar,
17+
FormatWim,
18+
FormatXz,
19+
FormatZip,
20+
OverwriteMode,
21+
lib7zip_context,
22+
)
23+
24+
WRITABLE_FORMATS = {
25+
"7z": FormatSevenZip,
26+
"zip": FormatZip,
27+
"tar": FormatTar,
28+
"gzip": FormatGZip,
29+
"bzip2": FormatBZip2,
30+
"xz": FormatXz,
31+
"wim": FormatWim,
32+
}
33+
34+
35+
def main() -> None:
36+
"""
37+
Main function to parse command-line arguments and perform compression/decompression operations.
38+
"""
39+
parser = argparse.ArgumentParser(
40+
description="pybit7z command-line compression tool"
41+
)
42+
parser.add_argument(
43+
"action",
44+
choices=["compress", "decompress", "list"],
45+
help="Operation type (compress/decompress/list)",
46+
)
47+
parser.add_argument("paths", nargs="+", help="File/directory paths to process")
48+
parser.add_argument(
49+
"-f",
50+
"--format",
51+
choices=WRITABLE_FORMATS.keys(),
52+
default="7z",
53+
help="Supported writable formats: 7z, zip, tar, gzip, bzip2, xz, wim",
54+
)
55+
output_group = parser.add_mutually_exclusive_group()
56+
output_group.add_argument(
57+
"-o",
58+
"--output",
59+
help="Output file/directory path (required for compress/decompress)",
60+
)
61+
output_group.add_argument(
62+
"-v", "--verbose", action="store_true", help="Show detailed file information"
63+
)
64+
parser.add_argument("-p", "--password", help="Encryption password")
65+
parser.add_argument(
66+
"-l",
67+
"--level",
68+
type=str,
69+
choices=BitCompressionLevel.__members__.keys(),
70+
default=BitCompressionLevel.Nothing.value,
71+
help="Compression level (%(choices)s)",
72+
)
73+
parser.add_argument(
74+
"--overwrite",
75+
choices=OverwriteMode.__members__.keys(),
76+
default=OverwriteMode.Overwrite.value,
77+
help="Overwrite mode when output exists (%(choices)s)",
78+
)
79+
80+
args = parser.parse_args()
81+
82+
if args.action in ["compress", "decompress"] and not args.output:
83+
parser.error("Output file/directory path is required")
84+
85+
try:
86+
with lib7zip_context() as lib:
87+
if args.action == "compress":
88+
compress(lib, args)
89+
elif args.action == "decompress":
90+
decompress(lib, args)
91+
else:
92+
list_archive(lib, args)
93+
except Exception as e: # pylint: disable=W0718
94+
sys.stderr.write(f"Error: {e}\n")
95+
sys.exit(1)
96+
97+
98+
def compress(lib: Bit7zLibrary, args: argparse.Namespace) -> None:
99+
"""
100+
Compress files/directories into a specified archive format.
101+
Args:
102+
lib (Bit7zLibrary): The 7z library instance.
103+
args (argparse.Namespace): Parsed command-line arguments.
104+
"""
105+
fmt = WRITABLE_FORMATS[args.format]
106+
assert isinstance(fmt, BitInOutFormat), (
107+
f"Format {args.format} is not supported for compression"
108+
)
109+
110+
writer = BitArchiveWriter(lib, fmt)
111+
112+
if args.password:
113+
writer.set_password(args.password)
114+
115+
writer.set_compression_level(BitCompressionLevel(args.level))
116+
writer.set_overwrite_mode(OverwriteMode(args.overwrite))
117+
118+
writer.add_items([path for path in args.paths if Path(path).exists()])
119+
120+
writer.compress_to(args.output)
121+
sys.stdout.write(f"Successfully created archive: {args.output}")
122+
123+
124+
def decompress(lib: Bit7zLibrary, args: argparse.Namespace) -> None:
125+
"""
126+
Decompress an archive to a specified directory.
127+
Args:
128+
lib (Bit7zLibrary): The 7z library instance.
129+
args (argparse.Namespace): Parsed command-line arguments.
130+
"""
131+
reader = BitArchiveReader(lib, args.paths[0])
132+
133+
if args.password:
134+
reader.set_password(args.password)
135+
136+
reader.extract_to(args.output)
137+
sys.stdout.write(f"Successfully extracted to: {args.output}")
138+
139+
140+
def list_archive(lib: Bit7zLibrary, args: argparse.Namespace) -> None:
141+
"""
142+
List the contents of an archive.
143+
Args:
144+
lib (Bit7zLibrary): The 7z library instance.
145+
args (argparse.Namespace): Parsed command-line arguments.
146+
"""
147+
reader = BitArchiveReader(lib, args.paths[0])
148+
149+
if args.password:
150+
reader.set_password(args.password)
151+
152+
# Printing archive metadata
153+
sys.stdout.write(
154+
f"Archive properties:\n Items count: {reader.items_count()}\n"
155+
f" Folders count: {reader.folders_count()}\n"
156+
f" Files count: {reader.files_count()}\n"
157+
f" Size: {reader.size()}\n"
158+
f" Packed size: {reader.pack_size()}"
159+
)
160+
161+
# Printing the metadata of the archived items
162+
sys.stdout.write("Archived items")
163+
for item in reader.items():
164+
sys.stdout.write(
165+
f" Item index: {item.index()}\n"
166+
f" Name: {item.name()}\n"
167+
f" Extension: {item.extension()}\n"
168+
f" Path: {item.path()}\n"
169+
f" IsDir: {item.is_dir()}\n"
170+
f" Size: {item.size()}\n"
171+
f" Packed size: {item.pack_size()}\n"
172+
f" CRC: {item.crc()}"
173+
)
174+
175+
176+
if __name__ == "__main__":
177+
main()

tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import random
4+
import string
5+
import tempfile
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
11+
@pytest.fixture
12+
def temp_dir():
13+
"""Fixture to provide temporary directory"""
14+
try:
15+
with tempfile.TemporaryDirectory() as tmp_dir:
16+
yield Path(tmp_dir)
17+
except Exception:
18+
... # Ignore errors from cleanup
19+
20+
21+
@pytest.fixture
22+
def large_file(tmp_path):
23+
"""Fixture to create a large test file (10MB)"""
24+
file_path: Path = tmp_path / "large_file.dat"
25+
size_mb = 10
26+
chunk_size = 1024 * 1024 # 1MB chunks
27+
28+
with file_path.open("wb") as f:
29+
for _ in range(size_mb):
30+
data = "".join(
31+
random.choices(string.ascii_letters + string.digits, k=chunk_size)
32+
).encode()
33+
f.write(data)
34+
35+
return file_path

tests/test_cli.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
5+
6+
def test_cli_help():
7+
"""Test the CLI help message"""
8+
result = subprocess.run(
9+
["pybit7z", "--help"], capture_output=True, text=True, check=False
10+
)
11+
assert result.returncode == 0
12+
assert "pybit7z command-line compression tool" in result.stdout
13+
14+
15+
def test_compress_basic(temp_dir):
16+
"""Test basic compression functionality"""
17+
test_file = temp_dir / "test.txt"
18+
test_file.write_text("sample content")
19+
output = temp_dir / "test.7z"
20+
21+
result = subprocess.run(
22+
["pybit7z", "compress", str(test_file), "-o", str(output), "-f", "7z"],
23+
capture_output=True,
24+
text=True,
25+
check=False,
26+
)
27+
28+
assert result.returncode == 0
29+
assert output.exists()
30+
assert "Successfully created archive" in result.stdout
31+
32+
33+
def test_decompress_basic(temp_dir):
34+
"""Test basic decompression functionality"""
35+
# Create test archive
36+
test_file = temp_dir / "test.txt"
37+
test_file.write_text("content")
38+
archive = temp_dir / "test.7z"
39+
subprocess.run(
40+
["pybit7z", "compress", str(test_file), "-o", str(archive)],
41+
capture_output=True,
42+
check=False,
43+
)
44+
45+
# Test decompression
46+
output_dir = temp_dir / "output"
47+
result = subprocess.run(
48+
["pybit7z", "decompress", str(archive), "-o", str(output_dir)],
49+
capture_output=True,
50+
text=True,
51+
check=False,
52+
)
53+
54+
assert result.returncode == 0
55+
assert (output_dir / test_file.name).exists()
56+
57+
58+
def test_invalid_format_handling(temp_dir):
59+
"""Test handling of invalid formats"""
60+
test_file = temp_dir / "test.txt"
61+
test_file.write_text("content")
62+
output = temp_dir / "test.invalid"
63+
64+
result = subprocess.run(
65+
["pybit7z", "compress", str(test_file), "-o", str(output), "-f", "invalid"],
66+
capture_output=True,
67+
text=True,
68+
check=False,
69+
)
70+
71+
assert result.returncode != 0
72+
assert "error: argument -f/--format" in result.stderr
73+
74+
75+
def test_password_protection(temp_dir):
76+
"""Test password protection for compression and decryption"""
77+
test_file = temp_dir / "secret.txt"
78+
test_file.write_text("confidential")
79+
output = temp_dir / "protected.7z"
80+
81+
# Compress with password
82+
compress_cmd = [
83+
"pybit7z",
84+
"compress",
85+
str(test_file),
86+
"-o",
87+
str(output),
88+
"-p",
89+
"mypassword",
90+
]
91+
assert subprocess.run(compress_cmd, check=False).returncode == 0
92+
93+
# Decompress without password should fail
94+
extract_dir = temp_dir / "extracted"
95+
result = subprocess.run(
96+
["pybit7z", "decompress", str(output), "-o", str(extract_dir)],
97+
capture_output=True,
98+
text=True,
99+
check=False,
100+
)
101+
assert result.returncode != 0
102+
assert "password" in result.stderr.lower()
103+
104+
105+
def test_list_output(temp_dir):
106+
"""
107+
Test the 'list' command to ensure it correctly lists files in an archive.
108+
"""
109+
# Create archive with multiple files
110+
archive = temp_dir / "multi.7z"
111+
files = [temp_dir / f"file{i}.txt" for i in range(3)]
112+
for f in files:
113+
f.write_text("data")
114+
115+
subprocess.run(
116+
["pybit7z", "compress"] + [str(f) for f in files] + ["-o", str(archive)],
117+
capture_output=True,
118+
check=False,
119+
)
120+
121+
# Test archive listing
122+
result = subprocess.run(
123+
["pybit7z", "list", str(archive)], capture_output=True, text=True, check=False
124+
)
125+
126+
assert result.returncode == 0
127+
for f in files:
128+
assert f.name in result.stdout

tests/test_pybit7z.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,13 @@
11
from __future__ import annotations
22

33
import datetime
4-
import random
5-
import string
6-
import tempfile
74
from pathlib import Path
85

96
import pytest
107

118
import pybit7z
129

1310

14-
@pytest.fixture
15-
def temp_dir():
16-
"""Fixture to provide temporary directory"""
17-
try:
18-
with tempfile.TemporaryDirectory() as tmp_dir:
19-
yield Path(tmp_dir)
20-
except Exception:
21-
... # Ignore errors from cleanup
22-
23-
24-
@pytest.fixture
25-
def large_file(temp_dir):
26-
"""Fixture to create a large test file (10MB)"""
27-
file_path: Path = temp_dir / "large_file.dat"
28-
size_mb = 10
29-
chunk_size = 1024 * 1024 # 1MB chunks
30-
31-
with file_path.open("wb") as f:
32-
for _ in range(size_mb):
33-
data = "".join(
34-
random.choices(string.ascii_letters + string.digits, k=chunk_size)
35-
).encode()
36-
f.write(data)
37-
38-
return file_path
39-
40-
4111
def test_format():
4212
"""Test format from bit7z"""
4313
assert pybit7z.FormatSevenZip.extension().endswith(".7z")

0 commit comments

Comments
 (0)