diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..5b060da Binary files /dev/null and b/.coverage differ diff --git a/debx/builder.py b/debx/builder.py index 4c1c3a2..b6381cf 100644 --- a/debx/builder.py +++ b/debx/builder.py @@ -117,11 +117,11 @@ def create_data_tar(self) -> bytes: with io.BytesIO() as fp: with tarfile.open(fileobj=fp, mode="w:bz2", format=tarfile.GNU_FORMAT, compresslevel=9) as tar: for directory_info in self.get_directories(): - logging.debug(f"Adding directory to data archive: %s", directory_info.path) + logging.debug("Adding directory to data archive: %s", directory_info.path) tar.addfile(directory_info) for item in sorted(self.data_files.values(), key=lambda x: x.tar_info.name): - logging.debug(f"Adding data to archive: %s", item.tar_info.name) + logging.debug("Adding data to archive: %s", item.tar_info.name) if item.tar_info.type == tarfile.SYMTYPE: tar.addfile(item.tar_info) else: diff --git a/debx/cli/inspect.py b/debx/cli/inspect.py index b6bfe97..7a335bb 100644 --- a/debx/cli/inspect.py +++ b/debx/cli/inspect.py @@ -19,46 +19,37 @@ log = logging.getLogger(__name__) -def format_ls(items: list[InspectItem]) -> str: - if not sys.stdout.isatty(): - sys.stderr.write( - "Hint: probably you trying process this output. Please see the output formats for better results.\n", - ) - def format_size(size: int) -> str: - if size == 0: - return "0B" - size_names = ("B", "K", "M", "G", "T", "P", "E", "Z", "Y") - i = int(math.floor(math.log(size, 1024))) - p = math.pow(1024, i) - s = round(size / p, 1) - if s.is_integer(): - s = int(s) - return f"{s}{size_names[i]}" - - def format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: - if mode is None: - return "----------" - - result = "" - - if item_type is not None: - if item_type == "directory" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.directory): - result += "d" - elif item_type == "symlink" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.symlink): - result += "l" - elif item_type == "char" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.char): - result += "c" - elif item_type == "block" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.block): - result += "b" - elif item_type == "fifo" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.fifo): - result += "p" - else: - if stat.S_ISDIR(mode): - result += "d" - elif stat.S_ISLNK(mode): - result += "l" - else: - result += "-" +def _format_size(size: int) -> str: + """Format size in human-readable format.""" + if size == 0: + return "0B" + size_names = ("B", "K", "M", "G", "T", "P", "E", "Z", "Y") + i = int(math.floor(math.log(size, 1024))) + p = math.pow(1024, i) + s = round(size / p, 1) + if s.is_integer(): + s = int(s) + return f"{s}{size_names[i]}" + + +def _format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: + """Format file mode as ls-style permission string.""" + if mode is None: + return "----------" + + result = "" + + if item_type is not None: + if item_type == "directory" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.directory): + result += "d" + elif item_type == "symlink" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.symlink): + result += "l" + elif item_type == "char" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.char): + result += "c" + elif item_type == "block" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.block): + result += "b" + elif item_type == "fifo" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.fifo): + result += "p" else: if stat.S_ISDIR(mode): result += "d" @@ -66,43 +57,59 @@ def format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: result += "l" else: result += "-" - result += "r" if mode & stat.S_IRUSR else "-" - result += "w" if mode & stat.S_IWUSR else "-" - result += "x" if mode & stat.S_IXUSR else "-" - result += "r" if mode & stat.S_IRGRP else "-" - result += "w" if mode & stat.S_IWGRP else "-" - result += "x" if mode & stat.S_IXGRP else "-" - result += "r" if mode & stat.S_IROTH else "-" - result += "w" if mode & stat.S_IWOTH else "-" - result += "x" if mode & stat.S_IXOTH else "-" - return result - - def format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: - if mtime is None: - return " " - - old_locale = locale.getlocale(locale.LC_TIME) - if user_locale: - try: - locale.setlocale(locale.LC_TIME, user_locale) - except locale.Error: - pass - - dt = datetime.datetime.fromtimestamp(mtime) - now = datetime.datetime.now() - - if dt.year == now.year: - result = dt.strftime("%d %b %H:%M") + else: + if stat.S_ISDIR(mode): + result += "d" + elif stat.S_ISLNK(mode): + result += "l" else: - result = dt.strftime("%d %b %Y") + result += "-" + result += "r" if mode & stat.S_IRUSR else "-" + result += "w" if mode & stat.S_IWUSR else "-" + result += "x" if mode & stat.S_IXUSR else "-" + result += "r" if mode & stat.S_IRGRP else "-" + result += "w" if mode & stat.S_IWGRP else "-" + result += "x" if mode & stat.S_IXGRP else "-" + result += "r" if mode & stat.S_IROTH else "-" + result += "w" if mode & stat.S_IWOTH else "-" + result += "x" if mode & stat.S_IXOTH else "-" + return result + + +def _format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: + """Format modification time in ls-style format.""" + if mtime is None: + return " " + + old_locale = locale.getlocale(locale.LC_TIME) + if user_locale: + try: + locale.setlocale(locale.LC_TIME, user_locale) + except locale.Error: + pass + + dt = datetime.datetime.fromtimestamp(mtime) + now = datetime.datetime.now() + + if dt.year == now.year: + result = dt.strftime("%d %b %H:%M") + else: + result = dt.strftime("%d %b %Y") + + if user_locale: + try: + locale.setlocale(locale.LC_TIME, old_locale) + except locale.Error: + locale.setlocale(locale.LC_TIME, 'C') + + return result - if user_locale: - try: - locale.setlocale(locale.LC_TIME, old_locale) - except locale.Error: - locale.setlocale(locale.LC_TIME, 'C') - return result +def format_ls(items: list[InspectItem]) -> str: + if not sys.stdout.isatty(): + sys.stderr.write( + "Hint: probably you trying process this output. Please see the output formats for better results.\n", + ) if not items: return "total 0" @@ -120,11 +127,11 @@ def format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: file_name = item["file"] + "/" + item.get("path", "") else: file_name = item["file"] - file_size = format_size(item.get("size", 0)) - file_mode = format_mode(item.get("mode", None), item.get("type", None)) + file_size = _format_size(item.get("size", 0)) + file_mode = _format_mode(item.get("mode", None), item.get("type", None)) file_uid = str(item.get("uid", 0)).rjust(max_uid_len) file_gid = str(item.get("gid", 0)).rjust(max_gid_len) - file_time = format_time(item.get("mtime", None)) + file_time = _format_time(item.get("mtime", None)) path_info = "" if item.get("path") and item.get("type") == "archive": diff --git a/pyproject.toml b/pyproject.toml index eaf9244..e55db70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "debx" -version = "0.2.11" -description = "Minimal Python library to programmatically construct Debian .deb packages" +version = "0.2.12" +description = "Minimal Python library to programmatically construct and inspect Debian .deb packages" readme = "README.md" authors = [ { name = "Dmitry Orlov", email = "me@mosquito.su" } @@ -9,16 +9,51 @@ authors = [ license = "MIT" license-files = ["COPYING"] requires-python = ">=3.10" +keywords = [ + "administrator", + "apt", + "ar", + "archive", + "automation", + "build", + "cross-platform", + "deb", + "debian", + "development", + "devops", + "dpkg", + "infrastructure", + "library", + "linux", + "macos", + "package", + "packaging", + "system", + "tool", + "windows", +] + classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Archiving :: Packaging", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Software Distribution", "Typing :: Typed", ] @@ -34,6 +69,7 @@ dev = [ "markdown-pytest>=0.3.2", "pytest>=9.0.2", "pytest-cov>=7.0.0", + "ruff>=0.14.10", ] [project.urls] @@ -44,3 +80,12 @@ dev = [ [project.scripts] "debx" = "debx.__main__:main" + +[tool.coverage.run] +branch = true +source = ["debx"] + +[tool.coverage.report] +exclude_lines = [ + "if __name__ == .__main__.:", +] diff --git a/tests/test_ar.py b/tests/test_ar.py index 215e173..eae891a 100644 --- a/tests/test_ar.py +++ b/tests/test_ar.py @@ -2,8 +2,8 @@ import pytest from io import BytesIO -from debx import ArFile, pack_ar_archive, unpack_ar_archive, EmptyHeaderError, TruncatedHeaderError - +from debx import ArFile, pack_ar_archive, unpack_ar_archive, EmptyHeaderError, TruncatedHeaderError, DebReader, \ + TruncatedDataError TEST_CONTENT = b"test file content" TEST_NAME = "testfile.txt" @@ -49,3 +49,104 @@ def test_arfile_from_file(tmp_path): assert ar_file.name == TEST_NAME assert ar_file.content == TEST_CONTENT + + +class TestReaderErrors: + """Tests for DebReader error handling.""" + + def test_missing_debian_binary(self): + """Test error when debian-binary is missing.""" + # Create an AR archive without debian-binary + ar_content = pack_ar_archive( + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(KeyError, match="Missing 'debian-binary'"): + DebReader(io.BytesIO(ar_content)) + + def test_invalid_debian_binary_version(self): + """Test error when debian-binary has wrong version.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"3.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(ValueError, match="Invalid debian-binary version"): + DebReader(io.BytesIO(ar_content)) + + def test_missing_data_tar(self): + """Test error when data.tar is missing.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ) + with pytest.raises(KeyError, match="Missing 'data.tar'"): + DebReader(io.BytesIO(ar_content)) + + def test_multiple_data_tar_files(self): + """Test error when multiple data.tar files exist.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(ValueError, match="Multiple data.tar files"): + DebReader(io.BytesIO(ar_content)) + + def test_unsupported_compression(self): + """Test error for unsupported compression format.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.xz"), + ) + with pytest.raises(ValueError, match="Unsupported compression format"): + DebReader(io.BytesIO(ar_content)) + + +class TestArErrors: + """Tests for AR archive error handling.""" + + def test_truncated_data_error(self): + """Test TruncatedDataError when data is incomplete.""" + # Create a header that claims more data than available + header = b"testfile.txt " # 16 bytes name + header += b"1234567890 " # 12 bytes mtime + header += b"0 " # 6 bytes uid + header += b"0 " # 6 bytes gid + header += b"100644 " # 8 bytes mode + header += b"1000 " # 10 bytes size (claims 1000 bytes) + header += b"\x60\x0A" # 2 bytes magic + + ar_archive = b"!\n" + header + b"short" # Only 5 bytes of data + + with pytest.raises(TruncatedDataError): + list(unpack_ar_archive(io.BytesIO(ar_archive))) + + +class TestEmptyArArchive: + """Tests for empty AR archive handling.""" + + def test_empty_ar_archive(self): + """Test unpacking empty AR archive.""" + # Just the AR magic, no files + ar_content = b"!\n" + files = list(unpack_ar_archive(io.BytesIO(ar_content))) + assert files == [] + + +class TestInvalidArArchive: + """Tests for invalid AR archive handling.""" + + def test_invalid_ar_magic(self): + """Test unpack_ar_archive with invalid magic bytes.""" + invalid_ar = b"INVALID!\nsome data" + with pytest.raises(ValueError, match="Invalid ar archive"): + list(unpack_ar_archive(io.BytesIO(invalid_ar))) + + def test_truncated_ar_magic(self): + """Test unpack_ar_archive with truncated magic bytes.""" + truncated_ar = b"!", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + + deb_content = builder.pack() + + # Verify package can be read + reader = DebReader(io.BytesIO(deb_content)) + names = reader.data.getnames() + assert "usr/bin/target" in names + assert "usr/bin/link" in names + + def test_directory_at_root(self): + """Test that root directory is skipped.""" + builder = DebBuilder() + builder.add_data_entry(b"content", "/file.txt") + + # get_directories should not include root + dirs = list(builder.get_directories()) + for d in dirs: + assert d.name != "/" + + +class TestRootDirectorySkip: + """Tests for root directory handling in DebBuilder.""" + + def test_add_file_at_root_creates_no_root_dir(self): + """Test that adding a file at root doesn't create '/' directory.""" + builder = DebBuilder() + # Add a file that would normally create "/" as parent + builder.add_data_entry(b"content", "/rootfile.txt") + + # Get directories - should not include root + dirs = list(builder.get_directories()) + dir_names = [str(d.name) for d in dirs] + + # "/" or empty string should not be in the directory list + assert "/" not in dir_names + assert "" not in dir_names + assert "." not in dir_names + + def test_root_directory_skip_in_get_directories(self): + """Test that get_directories skips root '/' directory.""" + builder = DebBuilder() + builder.add_data_entry(b"content", "/usr/bin/test") + + # Get one of the existing directories to determine the path type used + existing_dir = next(iter(builder.directories)) + # Create root path using the same path type for sorting compatibility + root_path = type(existing_dir)("/") + builder.directories.add(root_path) + + # Get directories - should skip "/" + dirs = list(builder.get_directories()) + + # "/" should be skipped + for d in dirs: + assert d.name != "/" + assert d.name != "" + + # But other directories should be present + # Normalize backslashes to forward slashes for cross-platform compatibility + dir_parts = [PurePosixPath(d.name.replace("\\", "/")).parts for d in dirs] + assert ("usr",) in dir_parts + assert ("usr", "bin") in dir_parts diff --git a/tests/test_cli_inspect.py b/tests/test_cli_inspect.py new file mode 100644 index 0000000..76ad69b --- /dev/null +++ b/tests/test_cli_inspect.py @@ -0,0 +1,1055 @@ +""" +Tests for CLI inspect command formatting and output. +""" +import csv +import io +import json +import stat +import time +from argparse import Namespace +from unittest.mock import patch + +import pytest + +from debx import ArFile, pack_ar_archive, DebBuilder, Deb822 +from debx.cli.inspect import cli_inspect, format_ls, format_csv, format_json +from debx.cli.types import InspectItem, TarInfoType + + +class TestStatModeFallback: + """Tests for stat mode fallback in format_mode.""" + + def test_format_ls_type_none_regular_mode(self): + """Test format_ls with type=None and regular file mode.""" + regular_mode = 0o100644 # Regular file with 644 permissions + items = [ + InspectItem( + file="regular.txt", + size=100, + type=None, + mode=regular_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as regular file with '-' prefix + assert any('-rw-r--r--' in line for line in lines) + + def test_format_ls_type_none_dir_mode(self): + """Test format_ls with type=None and directory mode.""" + dir_mode = stat.S_IFDIR | 0o755 # Directory with 755 permissions + items = [ + InspectItem( + file="mydir", + size=0, + type=None, + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as directory with 'd' prefix + assert any('drwxr-xr-x' in line for line in lines) + + def test_format_ls_type_none_symlink_mode(self): + """Test format_ls with type=None and symlink mode.""" + link_mode = stat.S_IFLNK | 0o777 # Symlink with 777 permissions + items = [ + InspectItem( + file="mylink", + size=0, + type=None, + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as symlink with 'l' prefix + assert any('lrwxrwxrwx' in line for line in lines) + + def test_format_ls_unknown_type_dir_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat dir check.""" + # Use an unknown type string but with directory mode + dir_mode = stat.S_IFDIR | 0o755 + items = [ + InspectItem( + file="unknown_dir", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'd' + assert any('d' in line for line in lines[1:]) + + def test_format_ls_unknown_type_symlink_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat symlink check.""" + # Use an unknown type string but with symlink mode + link_mode = stat.S_IFLNK | 0o777 + items = [ + InspectItem( + file="unknown_link", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'l' + assert any('l' in line for line in lines[1:]) + + +class TestFormatTimeLocale: + """Tests for format_time locale handling.""" + + def test_format_time_without_locale(self): + """Test _format_time without user_locale.""" + from debx.cli.inspect import _format_time + + current_time = int(time.time()) + result = _format_time(current_time) + assert len(result) > 0 + + def test_format_time_with_none_mtime(self): + """Test _format_time with None mtime.""" + from debx.cli.inspect import _format_time + + result = _format_time(None) + assert result == " " + + def test_format_time_with_valid_locale(self): + """Test _format_time with a valid locale.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + + # Use 'C' locale which should always be available + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 + + def test_format_time_with_invalid_locale_on_set(self): + """Test _format_time when setting locale fails.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + original_setlocale = locale.setlocale + + def mock_setlocale(category, loc=None): + if loc is not None and loc not in (None, '', ('en_US', 'UTF-8'), ('C', 'UTF-8'), 'C'): + raise locale.Error("Invalid locale") + return original_setlocale(category, loc) + + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + # This should not raise, just silently ignore the locale error + result = _format_time(current_time, user_locale='invalid_locale_xyz') + assert len(result) > 0 + + def test_format_time_with_locale_restore_error(self): + """Test _format_time when restoring locale fails and falls back to 'C'.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + original_setlocale = locale.setlocale + call_count = [0] + + def mock_setlocale(category, loc=None): + call_count[0] += 1 + # First call: getlocale returns tuple + # Second call: setting user_locale - allow it + # Third call: restoring old locale - fail + # Fourth call: fallback to 'C' - allow it + if call_count[0] == 3: + # Fail when trying to restore old locale + raise locale.Error("Cannot restore locale") + if loc == 'C' or loc is None: + return original_setlocale(category, loc) + # Allow setting user_locale + return original_setlocale(category, 'C') + + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + with patch.object(locale, 'getlocale', return_value=('invalid', 'locale')): + # This should not raise, should fallback to 'C' + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 + + +class TestFormatSizeDecimal: + """Tests for _format_size with decimal values.""" + + def test_format_size_decimal(self): + """Test _format_size with sizes that result in decimal values.""" + from debx.cli.inspect import _format_size + + # 1536 bytes = 1.5K (not an integer) + result = _format_size(1536) + assert result == "1.5K" + + # 2560 bytes = 2.5K + result = _format_size(2560) + assert result == "2.5K" + + def test_format_size_integer(self): + """Test _format_size with sizes that result in integer values.""" + from debx.cli.inspect import _format_size + + # 1024 bytes = 1K (integer) + result = _format_size(1024) + assert result == "1K" + + # 2048 bytes = 2K (integer) + result = _format_size(2048) + assert result == "2K" + + +class TestInspectFormatting: + """Tests for inspect formatting functions.""" + + def test_format_ls_empty_items(self): + """Test format_ls with empty list.""" + result = format_ls([]) + assert result == "total 0" + + def test_format_ls_mode_none(self): + """Test format_ls when mode is None.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=None, + uid=0, + gid=0, + mtime=None, + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "----------" in result + + def test_format_ls_symlink_type(self): + """Test format_ls with symlink type.""" + items = [ + InspectItem( + file="link", + size=0, + type="symlink", + mode=0o777, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert result.startswith("total") + assert "l" in result # symlink indicator + + def test_format_ls_directory_type(self): + """Test format_ls with directory type.""" + items = [ + InspectItem( + file="dir", + size=0, + type="directory", + mode=0o755, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result # directory indicator + + def test_format_ls_char_type(self): + """Test format_ls with char device type.""" + items = [ + InspectItem( + file="char", + size=0, + type="char", + mode=0o666, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "c" in result # char device indicator + + def test_format_ls_block_type(self): + """Test format_ls with block device type.""" + items = [ + InspectItem( + file="block", + size=0, + type="block", + mode=0o660, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "b" in result # block device indicator + + def test_format_ls_fifo_type(self): + """Test format_ls with fifo type.""" + items = [ + InspectItem( + file="fifo", + size=0, + type="fifo", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "p" in result # fifo indicator + + def test_format_ls_old_year(self): + """Test format_ls with old year timestamp.""" + # Use a timestamp from 2020 + old_time = 1577836800 # 2020-01-01 + items = [ + InspectItem( + file="old.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=old_time, + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "2020" in result + + def test_format_ls_with_path(self): + """Test format_ls with path set.""" + items = [ + InspectItem( + file="data.tar.gz", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path="usr/bin/test", + ) + ] + result = format_ls(items) + assert "data.tar.gz/usr/bin/test" in result + + def test_format_ls_stat_dir_mode(self): + """Test format_ls using stat.S_ISDIR for mode detection.""" + # Create item with directory mode but no explicit type + dir_mode = stat.S_IFDIR | 0o755 + items = [ + InspectItem( + file="dir", + size=0, + type=None, + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result + + def test_format_ls_stat_link_mode(self): + """Test format_ls using stat.S_ISLNK for mode detection.""" + # Create item with symlink mode but no explicit type + link_mode = stat.S_IFLNK | 0o777 + items = [ + InspectItem( + file="link", + size=0, + type=None, + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "l" in result + + def test_format_csv(self): + """Test format_csv function.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=1234567890, + md5="abc123", + path=None, + ) + ] + result = format_csv(items) + assert "file" in result + assert "test.txt" in result + assert "100" in result + + def test_format_json(self): + """Test format_json function.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=1234567890, + md5="abc123", + path=None, + ) + ] + result = format_json(items) + assert '"file": "test.txt"' in result + assert '"size": 100' in result + + +class TestCliInspect: + """Tests for CLI inspect command.""" + + def test_inspect_json_format(self, tmp_path): + """Test inspect with JSON format.""" + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="json") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "debian-binary" in output + + def test_inspect_csv_format(self, tmp_path): + """Test inspect with CSV format.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="csv") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "file" in output + + def test_inspect_unknown_format(self, tmp_path): + """Test inspect with unknown format.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="invalid") + with patch("sys.stderr", new_callable=io.StringIO) as mock_stderr: + result = cli_inspect(args) + + assert result == 1 + assert "Unknown format" in mock_stderr.getvalue() + + +class TestFormatLsIntegration: + """Integration tests for format_ls with TarInfoType.""" + + def test_format_ls_with_tarinfoType_directory(self): + """Test format_ls with TarInfoType.directory.""" + items = [ + InspectItem( + file="dir", + size=0, + type=TarInfoType.directory.name, + mode=0o755, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result + + def test_format_ls_with_tarinfoType_symlink(self): + """Test format_ls with TarInfoType.symlink.""" + items = [ + InspectItem( + file="link", + size=0, + type=TarInfoType.symlink.name, + mode=0o777, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "l" in result + + def test_format_ls_regular_file_with_type(self): + """Test format_ls with regular file type (not dir/symlink).""" + items = [ + InspectItem( + file="file.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + # Should have regular file indicator (-) + lines = result.strip().split('\n') + assert any(line.startswith('-') for line in lines[1:]) + + def test_format_ls_unknown_type_regular_mode(self): + """Test format_ls with unknown type but regular file mode.""" + items = [ + InspectItem( + file="file.txt", + size=100, + type="unknown_type", + mode=0o644, # Regular file mode + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + # Should fall through to stat check and show as regular file + lines = result.strip().split('\n') + assert len(lines) >= 2 + + def test_format_ls_archive_type_with_path(self): + """Test format_ls with archive type and path (shows arrow).""" + items = [ + InspectItem( + file="data.tar.gz", + size=100, + type="archive", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path="internal/file.txt", + ) + ] + result = format_ls(items) + assert " -> internal/file.txt" in result + + def test_format_ls_tty_hint(self): + """Test format_ls shows hint when not tty.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + with patch("sys.stdout.isatty", return_value=False): + with patch("sys.stderr.write") as mock_stderr: + format_ls(items) + # Should write hint to stderr + mock_stderr.assert_called() + + +class TestInspectXzFormat: + """Tests for XZ compressed packages.""" + + def test_inspect_tar_xz_package(self, tmp_path): + """Test inspecting package with .tar.xz data.""" + import tarfile + + # Create a .tar.xz file + xz_path = tmp_path / "data.tar.xz" + with tarfile.open(xz_path, "w:xz") as tar: + # Add a file to the tar + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create a minimal control.tar.gz + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create AR archive (deb package) + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(xz_path, "data.tar.xz"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar.xz" in output + + +class TestInspectNoMd5sums: + """Test inspect when md5sums file doesn't exist.""" + + def test_inspect_without_md5sums(self, tmp_path): + """Test inspecting package without md5sums in control.""" + import tarfile + + # Create control.tar.gz without md5sums + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create data.tar.bz2 + data_path = tmp_path / "data.tar.bz2" + with tarfile.open(data_path, "w:bz2") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create AR archive + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(data_path, "data.tar.bz2"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + + +class TestInspectNoControlTar: + """Tests for inspect when control.tar is missing.""" + + def test_inspect_without_control_tar(self, tmp_path): + """Test inspecting package without control.tar.""" + import tarfile + + # Create only data.tar.bz2, no control.tar + data_path = tmp_path / "data.tar.bz2" + with tarfile.open(data_path, "w:bz2") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create AR archive without control.tar + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(data_path, "data.tar.bz2"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar.bz2" in output + + +class TestInspectPlainTar: + """Test inspect with plain .tar files (no compression).""" + + def test_inspect_plain_tar_package(self, tmp_path): + """Test inspecting package with plain .tar data (mode='r').""" + import tarfile + + # Create a plain .tar file + tar_path = tmp_path / "data.tar" + with tarfile.open(tar_path, "w") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create control.tar.gz + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create AR archive + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(tar_path, "data.tar"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar" in output + + +class TestInspectIntegration: + """Integration tests for inspect command with all formats.""" + + @pytest.fixture + def test_package(self, tmp_path): + """Create a test deb package with various file types.""" + builder = DebBuilder() + + control = Deb822({ + "Package": "integration-test", + "Version": "1.2.3", + "Architecture": "amd64", + "Maintainer": "Test User ", + "Description": "Integration test package\n" + " This is a multi-line description.\n" + " Used for testing inspect formats.", + "Depends": "libc6", + }) + builder.add_control_entry("control", control.dump()) + + # Add various data files + builder.add_data_entry(b"#!/bin/bash\necho hello", "/usr/bin/hello", mode=0o755) + builder.add_data_entry(b"Configuration file", "/etc/hello.conf", mode=0o644) + builder.add_data_entry(b"Library content", "/usr/lib/libhello.so", mode=0o644) + builder.add_data_entry(b"Documentation", "/usr/share/doc/hello/README", mode=0o644) + + # Add a conffiles entry + builder.add_control_entry("conffiles", "/etc/hello.conf\n") + + pkg_path = tmp_path / "integration-test_1.2.3_amd64.deb" + pkg_path.write_bytes(builder.pack()) + + return pkg_path + + def test_inspect_json_format(self, test_package): + """Test inspect with JSON output format.""" + args = Namespace(package=str(test_package), format="json") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Parse JSON and verify structure + data = json.loads(output) + assert isinstance(data, list) + assert len(data) > 0 + + # Verify expected files are present + files = {item.get("path") or item.get("file") for item in data} + assert "debian-binary" in files + assert any("control.tar" in f for f in files) + assert any("data.tar" in f for f in files) + + # Verify data files are present + paths = {item.get("path") for item in data if item.get("path")} + assert "./usr/bin/hello" in paths or "usr/bin/hello" in paths + assert "./etc/hello.conf" in paths or "etc/hello.conf" in paths + + # Verify JSON structure has expected keys + for item in data: + assert "file" in item + assert "size" in item + assert "type" in item + assert "mode" in item + + def test_inspect_csv_format(self, test_package): + """Test inspect with CSV output format.""" + args = Namespace(package=str(test_package), format="csv") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Parse CSV and verify structure + reader = csv.reader(io.StringIO(output)) + rows = list(reader) + + # First row should be headers + headers = rows[0] + assert "file" in headers + assert "size" in headers + assert "type" in headers + assert "mode" in headers + assert "path" in headers + + # Should have data rows + assert len(rows) > 1 + + # Verify data files are in output + assert "debian-binary" in output + assert "control.tar" in output + assert "data.tar" in output + assert "usr/bin/hello" in output + + def test_inspect_find_format(self, test_package): + """Test inspect with find-style output format.""" + args = Namespace(package=str(test_package), format="find") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Verify output is line-based paths + lines = output.strip().split("\n") + assert len(lines) > 0 + + # Verify expected files/paths are present + assert "debian-binary" in output + assert any("control.tar" in line for line in lines) + assert any("data.tar" in line for line in lines) + assert any("usr/bin/hello" in line for line in lines) + assert any("etc/hello.conf" in line for line in lines) + + def test_inspect_ls_format(self, test_package): + """Test inspect with ls-style output format.""" + args = Namespace(package=str(test_package), format="ls") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Verify ls-style output structure + lines = output.strip().split("\n") + assert lines[0].startswith("total ") + + # Verify permission strings are present + assert any(line.startswith("-r") for line in lines) # regular files + + # Verify expected files are present + assert "debian-binary" in output + assert "control.tar" in output + assert "data.tar" in output + assert "usr/bin/hello" in output + assert "etc/hello.conf" in output + + # Verify human-readable sizes are present (e.g., "B", "K", "M") + assert any(c in output for c in ["B", "K", "M"]) + + def test_inspect_ls_format_non_tty(self, test_package): + """Test inspect with ls format when stdout is not a tty (shows hint).""" + args = Namespace(package=str(test_package), format="ls") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=False): + with patch("sys.stderr", new_callable=io.StringIO) as mock_stderr: + result = cli_inspect(args) + + assert result == 0 + # Should show hint about using other formats + assert "Hint" in mock_stderr.getvalue() + # But still produce output + assert "total" in mock_stdout.getvalue() + + def test_inspect_all_formats_consistency(self, test_package): + """Test that all formats contain the same files.""" + formats = ["json", "csv", "find", "ls"] + file_counts = {} + + for fmt in formats: + args = Namespace(package=str(test_package), format=fmt) + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0, f"Format {fmt} failed" + + output = mock_stdout.getvalue() + # Count mentions of key files + file_counts[fmt] = { + "debian-binary": output.count("debian-binary"), + "hello": output.count("hello"), + } + + # All formats should mention debian-binary exactly once + for fmt in formats: + assert file_counts[fmt]["debian-binary"] >= 1, f"Format {fmt} missing debian-binary" + assert file_counts[fmt]["hello"] >= 1, f"Format {fmt} missing hello files" + + +class TestLocaleHandling: + """Tests for locale handling in format_time.""" + + def test_format_ls_with_locale_error(self): + """Test format_ls when locale setting fails.""" + import locale + + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + + # Mock locale.setlocale to raise an error + original_setlocale = locale.setlocale + call_count = [0] + + def failing_setlocale(category, locale_str=None): + call_count[0] += 1 + if call_count[0] == 1: + # First call (getting old locale) succeeds + return original_setlocale(category, locale_str) + elif locale_str and locale_str != 'C': + # Setting new locale fails + raise locale.Error("test error") + return original_setlocale(category, locale_str) + + with patch.object(locale, 'setlocale', side_effect=failing_setlocale): + # This should not raise an error + result = format_ls(items) + + assert "test.txt" in result diff --git a/tests/test_cli_pack.py b/tests/test_cli_pack.py new file mode 100644 index 0000000..14c9803 --- /dev/null +++ b/tests/test_cli_pack.py @@ -0,0 +1,143 @@ +""" +Tests for CLI pack command. +""" +import tempfile +from argparse import ArgumentTypeError +from pathlib import Path +from unittest.mock import patch + +import pytest + +from debx.cli.pack import parse_file + + +class TestParseFile: + def test_invalid_format(self): + """Test that parse_file raises an error for invalid formats""" + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + list(parse_file("no_colon_here")) + + def test_simple_file(self, tmp_path): + """Test parsing a simple file with no modifiers""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + result = list(parse_file(f"{test_file}:/dest/path")) + assert len(result) == 1 + assert str(result[0]["name"]) == "/dest/path" + assert result[0]["content"] == b"test content" + + def test_file_with_modifiers(self, tmp_path): + """Test parsing a file with modifiers""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + result = list(parse_file(f"{test_file}:/dest/path:mode=0755,uid=1000,gid=2000,mtime=1234567890")) + assert len(result) == 1 + assert str(result[0]["name"]) == "/dest/path" + assert result[0]["content"] == b"test content" + assert result[0]["mode"] == 0o755 + assert result[0]["uid"] == 1000 + assert result[0]["gid"] == 2000 + assert result[0]["mtime"] == 1234567890 + + def test_directory(self, tmp_path): + """Test parsing a directory""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + file1 = test_dir / "file1.txt" + file1.write_text("file1 content") + + subdir = test_dir / "subdir" + subdir.mkdir() + + file2 = subdir / "file2.txt" + file2.write_text("file2 content") + + result = list(parse_file(f"{test_dir}:/dest/path")) + assert len(result) == 2 + + # Sort results to ensure consistent order for testing + result.sort(key=lambda x: str(x["name"])) + + assert str(result[0]["name"]) == "/dest/path/file1.txt" + assert result[0]["content"] == b"file1 content" + + assert str(result[1]["name"]) == "/dest/path/subdir/file2.txt" + assert result[1]["content"] == b"file2 content" + + def test_relative_path_error(self, tmp_path): + """Test that relative destination paths raise an error""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + with pytest.raises(ArgumentTypeError, match="Destination path must be absolute"): + list(parse_file(f"{test_file}:relative/path")) + + +class TestPackDirectoryErrors: + """Tests for pack command directory error handling.""" + + def test_parse_file_relative_dest_error(self): + """Test parse_file with relative destination path.""" + with tempfile.TemporaryDirectory() as tmp: + test_file = Path(tmp) / "test.txt" + test_file.write_bytes(b"content") + + with pytest.raises(ArgumentTypeError, match="must be absolute"): + parse_file(f"{test_file}:relative/path") + + +class TestPackDirectoryMode: + """Tests for pack command directory handling.""" + + def test_parse_file_directory_with_mode(self, tmp_path): + """Test parse_file with directory and mode modifier shows warning.""" + # Create a directory with a file + test_dir = tmp_path / "mydir" + test_dir.mkdir() + (test_dir / "file.txt").write_bytes(b"content") + + with patch("sys.stderr.write") as mock_stderr: + result = list(parse_file(f"{test_dir}:/opt/mydir:mode=0755")) + + # Should have called stderr.write with warning + mock_stderr.assert_called() + assert len(result) == 1 + + def test_parse_file_unsupported_type(self, tmp_path): + """Test parse_file with unsupported file type (non-existent path).""" + # Use a path that exists but is neither file nor directory nor symlink + # by mocking Path.is_file and Path.is_dir to return False + nonexistent = tmp_path / "nonexistent" + + with pytest.raises((ArgumentTypeError, FileNotFoundError)): + list(parse_file(f"{nonexistent}:/var/run/test")) + + def test_parse_file_invalid_regex_match(self): + """Test parse_file when regex doesn't match.""" + # This has a colon but doesn't match the regex properly + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("::") # Edge case that has colons but invalid format + + +class TestCliPack: + """Tests for CLI pack command.""" + + def test_parse_file_no_colon(self): + """Test parse_file with missing colon.""" + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("nocolon") + + def test_parse_file_symlink(self, tmp_path): + """Test parse_file with symlink.""" + # Create a symlink + target = tmp_path / "target" + target.write_bytes(b"content") + link = tmp_path / "link" + link.symlink_to(target) + + result = list(parse_file(f"{link}:/usr/bin/link")) + assert len(result) == 1 + assert result[0]["name"] == "/usr/bin/link" diff --git a/tests/test_cli_sign.py b/tests/test_cli_sign.py new file mode 100644 index 0000000..793eef9 --- /dev/null +++ b/tests/test_cli_sign.py @@ -0,0 +1,296 @@ +""" +Tests for CLI sign command. +""" +import io +from argparse import Namespace +from unittest.mock import patch, MagicMock + +import pytest + +from debx import ArFile, pack_ar_archive, unpack_ar_archive, DebBuilder, Deb822 +from debx.cli.sign import cli_sign_extract_payload, cli_sign_write_signature, cli_sign + + +@pytest.fixture +def mock_package(tmp_path): + control_file = ArFile(name="control.tar.gz", content=b"control content", size=15) + data_file = ArFile(name="data.tar.gz", content=b"data content", size=12) + package_path = tmp_path / "test.deb" + package_path.write_bytes(pack_ar_archive(control_file, data_file)) + return package_path + + +def test_cli_sign_extract_payload(mock_package, capsys): + args = MagicMock() + args.package = mock_package + args.output = None + + with patch("sys.stdout", new_callable=io.BytesIO) as mock_stdout: + mock_stdout.buffer = mock_stdout + + result = cli_sign_extract_payload(args) + assert result == 0 + + output = mock_stdout.getvalue() + assert b"control content" in output + assert b"data content" in output + + +def test_cli_sign_write_signature(mock_package, tmp_path): + signature = b"-----BEGIN PGP SIGNATURE-----\nMockSignature\n-----END PGP SIGNATURE-----" + output_path = tmp_path / "signed.deb" + + args = MagicMock() + args.package = mock_package + args.output = output_path + + with patch("sys.stdin", new=io.BytesIO(signature)) as mock_stdin: + mock_stdin.buffer = mock_stdin + result = cli_sign_write_signature(args) + assert result == 0 + + with output_path.open("rb") as f: + files = list(unpack_ar_archive(f)) + assert any(file.name == "_gpgorigin" and file.content == signature for file in files) + + +def test_cli_sign_invalid_arguments(mock_package): + args = MagicMock() + args.extract = True + args.update = True + args.package = mock_package + args.output = None + + with patch("debx.cli.sign.log.error") as mock_log: + result = cli_sign(args) + assert result == 1 + mock_log.assert_called_with("Cannot use --extract and --update at the same time") + + args.extract = False + args.update = False + + with patch("debx.cli.sign.log.error") as mock_log: + result = cli_sign(args) + assert result == 1 + mock_log.assert_called_with("No action specified") + + +class TestCliSign: + """Tests for CLI sign command.""" + + def test_sign_extract_tty_error(self, tmp_path): + """Test sign extract when stdout is tty.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + with patch("sys.stdout.isatty", return_value=True): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_control(self, tmp_path): + """Test sign extract when control file is missing.""" + # Create package without control + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"data", "data.tar.bz2"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_data(self, tmp_path): + """Test sign extract when data file is missing.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"control", "control.tar.gz"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_write_invalid_signature(self, tmp_path): + """Test sign write with invalid signature.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_path = tmp_path / "signed.deb" + args = Namespace(package=pkg_path, output=output_path) + + with patch("sys.stdin.buffer.read", return_value=b"invalid signature"): + result = cli_sign_write_signature(args) + + assert result == 1 + + def test_sign_both_flags_error(self, tmp_path): + """Test sign with both --extract and --update.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=True, output=None) + result = cli_sign(args) + + assert result == 1 + + def test_sign_extract_with_output_error(self, tmp_path): + """Test sign extract with --output flag.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace( + package=pkg_path, extract=True, update=False, + output=tmp_path / "out.deb" + ) + result = cli_sign(args) + + assert result == 1 + + def test_sign_update_default_output(self, tmp_path): + """Test sign update with default output path.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + args = Namespace(package=pkg_path, extract=False, update=True, output=None) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert (tmp_path / "test.signed.deb").exists() + + def test_sign_update_custom_output(self, tmp_path): + """Test sign update with custom output path (covers branch 87->89).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + custom_output = tmp_path / "custom_output.deb" + args = Namespace(package=pkg_path, extract=False, update=True, output=custom_output) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert custom_output.exists() + + def test_sign_no_action_error(self, tmp_path): + """Test sign with no action specified.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=False, update=False, output=None) + result = cli_sign(args) + + assert result == 1 + + +class TestSignExtractSuccess: + """Test successful sign extract operation.""" + + def test_sign_extract_success(self, tmp_path): + """Test sign extract with valid package.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign_extract_payload(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 + + def test_sign_extract_via_cli_sign(self, tmp_path): + """Test sign extract success through cli_sign function (covers line 85).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 diff --git a/tests/test_cli_unpack.py b/tests/test_cli_unpack.py new file mode 100644 index 0000000..9c98668 --- /dev/null +++ b/tests/test_cli_unpack.py @@ -0,0 +1,90 @@ +""" +Tests for CLI unpack command. +""" +import os +from argparse import Namespace + +from debx import DebBuilder, Deb822 +from debx.cli.unpack import cli_unpack + + +class TestCliUnpack: + """Tests for CLI unpack command.""" + + def test_unpack_default_directory(self, tmp_path): + """Test unpack with default directory name.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "mypackage.deb" + pkg_path.write_bytes(builder.pack()) + + # Change to tmp_path so the default directory is created there + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + args = Namespace(package=str(pkg_path), directory=None, keep_archives=False) + result = cli_unpack(args) + finally: + os.chdir(old_cwd) + + assert result == 0 + assert (tmp_path / "mypackage").exists() + + def test_unpack_keep_archives(self, tmp_path): + """Test unpack with --keep-archives flag.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=True) + result = cli_unpack(args) + + assert result == 0 + assert (output_dir / "control.tar.gz").exists() + assert (output_dir / "data.tar.bz2").exists() + + def test_unpack_with_directory(self, tmp_path): + """Test unpack with specified directory.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "custom_output" + args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=False) + result = cli_unpack(args) + + assert result == 0 + assert output_dir.exists() + assert (output_dir / "debian-binary").exists() + assert (output_dir / "control").exists() + assert (output_dir / "data").exists() diff --git a/tests/test_deb822.py b/tests/test_deb822.py index b379dc0..ae10206 100644 --- a/tests/test_deb822.py +++ b/tests/test_deb822.py @@ -125,3 +125,21 @@ def test_mapping_update(): assert deb["Package"] == "pkg" deb.update([("Arch", "amd64"), ("Priority", "optional")]) assert set(deb.keys()) == {"Package", "Version", "Arch", "Priority"} + + +class TestDeb822ContinuationWithoutField: + """Tests for Deb822 parsing edge cases.""" + + def test_continuation_line_without_prior_field(self): + """Test parsing continuation line without prior field definition.""" + # A line starting with space but no prior field defined + text = " continuation without field\nPackage: test\n" + result = Deb822.parse(text) + # Should just skip the orphan continuation line + assert result["Package"] == "test" + + def test_continuation_after_comment(self): + """Test continuation line after a comment.""" + text = "# comment\n continuation\nPackage: test\n" + result = Deb822.parse(text) + assert result["Package"] == "test" diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 0000000..cc5345e --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,112 @@ +""" +Tests for CLI entry point (__main__.py). +""" +import io +import sys +from unittest.mock import patch + +import pytest + +from debx import DebBuilder, Deb822 + + +class TestEntryPoint: + """Tests for __main__.py entry point.""" + + def test_main_no_args(self): + """Test main with no arguments shows help.""" + from debx.__main__ import main, PARSER + + with patch.object(sys, 'argv', ['debx']): + with patch.object(PARSER, 'print_help') as mock_help: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_help.assert_called_once() + + def test_main_inspect_command(self, tmp_path): + """Test main with inspect command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + with patch.object(sys, 'argv', ['debx', 'inspect', str(pkg_path)]): + with patch("sys.stdout", new_callable=io.StringIO): + with patch("sys.stdout.isatty", return_value=True): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_main_pack_command(self, tmp_path): + """Test main with pack command.""" + from debx.__main__ import main + + # Create control file + control_file = tmp_path / "control" + control_file.write_text("""Package: test +Version: 1.0 +Architecture: all +Maintainer: Test +Description: Test +""") + + # Create data file + data_file = tmp_path / "binary" + data_file.write_bytes(b"#!/bin/sh\necho hello") + + output_path = tmp_path / "output.deb" + + with patch.object(sys, 'argv', [ + 'debx', 'pack', + '-c', f'{control_file}:/control', + '-d', f'{data_file}:/usr/bin/test:mode=0755', + '-o', str(output_path) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_path.exists() + + def test_main_unpack_command(self, tmp_path): + """Test main with unpack command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + + with patch.object(sys, 'argv', [ + 'debx', 'unpack', str(pkg_path), '-d', str(output_dir) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_dir.exists() diff --git a/tests/test_main.py b/tests/test_integration.py similarity index 55% rename from tests/test_main.py rename to tests/test_integration.py index 17ca9c2..fe0215a 100644 --- a/tests/test_main.py +++ b/tests/test_integration.py @@ -1,82 +1,16 @@ -import io +""" +Integration tests for CLI commands working together. +""" import os -from argparse import ArgumentTypeError +from unittest.mock import MagicMock import pytest -from unittest.mock import MagicMock, patch -from debx import ArFile, pack_ar_archive, unpack_ar_archive from debx.cli.inspect import cli_inspect -from debx.cli.pack import parse_file, cli_pack -from debx.cli.sign import cli_sign_extract_payload, cli_sign_write_signature, cli_sign +from debx.cli.pack import cli_pack from debx.cli.unpack import cli_unpack -class TestParseFile: - def test_invalid_format(self): - """Test that parse_file raises an error for invalid formats""" - with pytest.raises(ArgumentTypeError, match="Invalid file format"): - list(parse_file("no_colon_here")) - - def test_simple_file(self, tmp_path): - """Test parsing a simple file with no modifiers""" - test_file = tmp_path / "test.txt" - test_file.write_text("test content") - - result = list(parse_file(f"{test_file}:/dest/path")) - assert len(result) == 1 - assert str(result[0]["name"]) == "/dest/path" - assert result[0]["content"] == b"test content" - - def test_file_with_modifiers(self, tmp_path): - """Test parsing a file with modifiers""" - test_file = tmp_path / "test.txt" - test_file.write_text("test content") - - result = list(parse_file(f"{test_file}:/dest/path:mode=0755,uid=1000,gid=2000,mtime=1234567890")) - assert len(result) == 1 - assert str(result[0]["name"]) == "/dest/path" - assert result[0]["content"] == b"test content" - assert result[0]["mode"] == 0o755 - assert result[0]["uid"] == 1000 - assert result[0]["gid"] == 2000 - assert result[0]["mtime"] == 1234567890 - - def test_directory(self, tmp_path): - """Test parsing a directory""" - test_dir = tmp_path / "test_dir" - test_dir.mkdir() - - file1 = test_dir / "file1.txt" - file1.write_text("file1 content") - - subdir = test_dir / "subdir" - subdir.mkdir() - - file2 = subdir / "file2.txt" - file2.write_text("file2 content") - - result = list(parse_file(f"{test_dir}:/dest/path")) - assert len(result) == 2 - - # Sort results to ensure consistent order for testing - result.sort(key=lambda x: str(x["name"])) - - assert str(result[0]["name"]) == "/dest/path/file1.txt" - assert result[0]["content"] == b"file1 content" - - assert str(result[1]["name"]) == "/dest/path/subdir/file2.txt" - assert result[1]["content"] == b"file2 content" - - def test_relative_path_error(self, tmp_path): - """Test that relative destination paths raise an error""" - test_file = tmp_path / "test.txt" - test_file.write_text("test content") - - with pytest.raises(ArgumentTypeError, match="Destination path must be absolute"): - list(parse_file(f"{test_file}:relative/path")) - - @pytest.fixture def test_package_structure(tmp_path): """Create a test package structure for integration tests""" @@ -114,7 +48,9 @@ def test_package_structure(tmp_path): return tmp_path -class TestIntegration: +class TestPackUnpackIntegration: + """Integration tests for pack and unpack commands.""" + def test_pack_and_unpack(self, test_package_structure, tmp_path): """Integration test for packing and unpacking a deb package""" # Skip if running in CI without proper permissions @@ -160,9 +96,11 @@ def test_pack_and_unpack(self, test_package_structure, tmp_path): assert (extract_dir / "data").exists() -class TestInspect: +class TestPackInspectIntegration: + """Integration tests for pack and inspect commands.""" + def test_inspect(self, test_package_structure): - """Test the inspect command""" + """Test the inspect command on a packed package""" package_dir = test_package_structure output_deb = package_dir / "output.deb" @@ -193,7 +131,7 @@ def test_inspect(self, test_package_structure): # Verify output assert output_deb.exists() - def test_inspect_format_lst(self, test_package_structure): + def test_inspect_format_ls(self, test_package_structure): """Test the inspect command with --format=ls""" package_dir = test_package_structure output_deb = package_dir / "output.deb" @@ -258,67 +196,3 @@ def test_inspect_format_find(self, test_package_structure): # Verify output assert output_deb.exists() - - -@pytest.fixture -def mock_package(tmp_path): - control_file = ArFile(name="control.tar.gz", content=b"control content", size=15) - data_file = ArFile(name="data.tar.gz", content=b"data content", size=12) - package_path = tmp_path / "test.deb" - package_path.write_bytes(pack_ar_archive(control_file, data_file)) - return package_path - - -def test_cli_sign_extract_payload(mock_package, capsys): - args = MagicMock() - args.package = mock_package - args.output = None - - with patch("sys.stdout", new_callable=io.BytesIO) as mock_stdout: - mock_stdout.buffer = mock_stdout - - result = cli_sign_extract_payload(args) - assert result == 0 - - output = mock_stdout.getvalue() - assert b"control content" in output - assert b"data content" in output - - -def test_cli_sign_write_signature(mock_package, tmp_path): - signature = b"-----BEGIN PGP SIGNATURE-----\nMockSignature\n-----END PGP SIGNATURE-----" - output_path = tmp_path / "signed.deb" - - args = MagicMock() - args.package = mock_package - args.output = output_path - - with patch("sys.stdin", new=io.BytesIO(signature)) as mock_stdin: - mock_stdin.buffer = mock_stdin - result = cli_sign_write_signature(args) - assert result == 0 - - with output_path.open("rb") as f: - files = list(unpack_ar_archive(f)) - assert any(file.name == "_gpgorigin" and file.content == signature for file in files) - - -def test_cli_sign_invalid_arguments(mock_package): - args = MagicMock() - args.extract = True - args.update = True - args.package = mock_package - args.output = None - - with patch("debx.cli.sign.log.error") as mock_log: - result = cli_sign(args) - assert result == 1 - mock_log.assert_called_with("Cannot use --extract and --update at the same time") - - args.extract = False - args.update = False - - with patch("debx.cli.sign.log.error") as mock_log: - result = cli_sign(args) - assert result == 1 - mock_log.assert_called_with("No action specified") diff --git a/uv.lock b/uv.lock index 9a9323d..c2b6e20 100644 --- a/uv.lock +++ b/uv.lock @@ -238,7 +238,7 @@ wheels = [ [[package]] name = "debx" -version = "0.2.10" +version = "0.2.12" source = { editable = "." } [package.dev-dependencies] @@ -247,6 +247,7 @@ dev = [ { name = "markdown-pytest" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -257,6 +258,7 @@ dev = [ { name = "markdown-pytest", specifier = ">=0.3.2" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, ] [[package]] @@ -394,6 +396,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + [[package]] name = "tomli" version = "2.3.0"