diff --git a/README.md b/README.md index 0bcb9b0..3fabd42 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,39 @@ ![debx logo](https://raw.githubusercontent.com/mosquito/debx/master/logo.png "Logo") -Pronounced "deb-ex", `debx` is a Python library for creating, reading, and manipulating Debian package files. -This package includes the `debx` command-line tool for packing, unpacking, and inspecting any .deb packages. +Pronounced "deb-ex", `debx` is a minimal Python library for creating, reading, and manipulating Debian package files (.deb). It includes a powerful command-line tool for packing, unpacking, inspecting, and signing packages. + +## Table of Contents + +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [User Guide (CLI)](#user-guide-cli) + - [Inspecting Packages](#inspecting-packages) + - [Unpacking Packages](#unpacking-packages) + - [Packing Packages](#packing-packages) + - [Signing Packages](#signing-packages) +- [Developer Guide (Python API)](#developer-guide-python-api) + - [Creating Packages with DebBuilder](#creating-packages-with-debbuilder) + - [Reading Packages with DebReader](#reading-packages-with-debreader) + - [Working with Control Files (Deb822)](#working-with-control-files-deb822) + - [Low-Level AR Archive Operations](#low-level-ar-archive-operations) +- [Tutorials](#tutorials) + - [Tutorial 1: Creating a Simple Package](#tutorial-1-creating-a-simple-package) + - [Tutorial 2: Extracting and Modifying a Package](#tutorial-2-extracting-and-modifying-a-package) + - [Tutorial 3: Building a Python Application Package](#tutorial-3-building-a-python-application-package) +- [API Reference](#api-reference) +- [License](#license) +- [Contributing](#contributing) ## Features -- Cross-platform support for creating and unpacking .deb packages. Yes you can create .deb packages on Windows! -- Read and extract content from Debian packages -- Create custom Debian packages programmatically -- Parse and manipulate Debian control files (RFC822-style format) -- Low-level AR archive manipulation -- No external dependencies - uses only Python standard library -- Command-line interface for creating and unpacking .deb packages +- **Cross-platform** - Create .deb packages on Linux, macOS, and Windows +- **Zero dependencies** - Uses only Python standard library +- **Full package lifecycle** - Read, create, modify, and sign packages +- **CLI and API** - Use from command line or integrate into Python applications +- **Type hints** - Full type annotation support for modern Python development +- **Multiple output formats** - Inspect packages as JSON, CSV, or ls-style output ## Installation @@ -23,184 +44,1257 @@ This package includes the `debx` command-line tool for packing, unpacking, and i pip install debx ``` +Or with [uv](https://docs.astral.sh/uv/): + +```bash +uv pip install debx +``` + +Requires Python 3.10 or later. + ## Quick Start -### Reading a Debian Package +### 30-Second CLI Example + +```bash +# Inspect a package +debx inspect mypackage.deb + +# Unpack a package to a directory +debx unpack mypackage.deb -d ./unpacked + +# Create a package from files +debx pack \ + --control control:/control \ + --data myapp:/usr/bin/myapp:mode=0755 \ + -o mypackage.deb +``` + +### 30-Second Python Example + ```python -from debx import DebReader +import os +import tempfile +from debx import DebBuilder, Deb822 + +# Create a new package +builder = DebBuilder() + +# Add control metadata +control = Deb822({ + "Package": "hello-world", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "You ", + "Description": "A hello world package", +}) +builder.add_control_entry("control", control.dump()) + +# Add an executable +builder.add_data_entry( + b"#!/bin/sh\necho 'Hello, World!'\n", + "/usr/bin/hello-world", + mode=0o755 +) + +# Write the package +with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "hello-world_1.0.0_all.deb") + with open(path, "wb") as f: + f.write(builder.pack()) + assert os.path.exists(path) +``` + +--- + +# User Guide (CLI) + +The `debx` command-line tool provides four main commands for working with Debian packages. + +## Inspecting Packages + +The `inspect` command displays the contents of a .deb package. + +### Basic Usage + +```bash +debx inspect package.deb +``` + +This displays an `ls -lah` style listing of all files in the package: + +``` +total 42 +-rw-r--r-- 0 0 4 06 May 15:30 debian-binary +-rw-r--r-- 0 0 512 06 May 15:30 control.tar.gz +-rw-r--r-- 0 0 512 06 May 15:30 control.tar.gz/control +-rw-r--r-- 0 0 128 06 May 15:30 control.tar.gz/md5sums +-rw-r--r-- 0 0 1024 06 May 15:30 data.tar.bz2 +drwxr-xr-x 0 0 0 06 May 15:30 data.tar.bz2/usr/ +drwxr-xr-x 0 0 0 06 May 15:30 data.tar.bz2/usr/bin/ +-rwxr-xr-x 0 0 256 06 May 15:30 data.tar.bz2/usr/bin/myapp +``` + +### Output Formats + +Use the `--format` option to change the output format: + +| Format | Description | Use Case | +|--------|-------------|----------| +| `ls` | ls -lah style (default) | Human-readable inspection | +| `json` | Structured JSON | Programmatic processing | +| `csv` | Comma-separated values | Spreadsheet import | +| `find` | File paths only | Piping to other tools | + +#### JSON Format + +```bash +debx inspect --format=json package.deb +``` + +```json +[ + { + "file": "debian-binary", + "size": 4, + "type": "regular", + "mode": 33188, + "uid": 0, + "gid": 0, + "mtime": 1715006234, + "md5": "a1b2c3d4e5f6...", + "path": null + } +] +``` + +#### CSV Format + +```bash +debx inspect --format=csv package.deb > contents.csv +``` + +#### Find Format + +```bash +debx inspect --format=find package.deb | grep usr/bin +``` + +### Logging + +Enable debug logging to see detailed processing information: + +```bash +debx --log-level=debug inspect package.deb +``` + +## Unpacking Packages + +The `unpack` command extracts the contents of a .deb package. + +### Basic Usage + +```bash +debx unpack package.deb +``` + +This creates a directory named after the package (without `.deb` extension): + +``` +package/ +├── control/ +│ ├── control +│ ├── md5sums +│ ├── preinst +│ └── postinst +├── data/ +│ └── usr/ +│ └── bin/ +│ └── myapp +└── debian-binary +``` + +### Specify Output Directory + +```bash +debx unpack package.deb -d /tmp/extracted +``` + +### Keep Original Archives + +By default, the tar archives are extracted and removed. To keep them: + +```bash +debx unpack package.deb --keep-archives +``` + +This preserves `control.tar.gz` and `data.tar.bz2` alongside the extracted directories. + +## Packing Packages + +The `pack` command creates a .deb package from files and directories. + +### Basic Usage + +```bash +debx pack \ + --control path/to/control:/control \ + --data path/to/binary:/usr/bin/myapp:mode=0755 \ + -o mypackage.deb +``` + +### File Format Specification + +Files are specified in the format: + +``` +source_path:destination_path[:modifiers] +``` + +| Component | Description | +|-----------|-------------| +| `source_path` | Local path to the file or directory | +| `destination_path` | Absolute path inside the package | +| `modifiers` | Optional comma-separated key=value pairs | + +### Available Modifiers + +| Modifier | Description | Example | +|----------|-------------|---------| +| `mode` | File permissions (octal) | `mode=0755` | +| `uid` | Owner user ID | `uid=1000` | +| `gid` | Owner group ID | `gid=1000` | +| `mtime` | Modification time (Unix timestamp) | `mtime=1715006234` | + +### Control Files + +Control files are added with the `-c` or `--control` option: + +```bash +debx pack \ + --control control:/control \ + --control preinst:/preinst:mode=0755 \ + --control postinst:/postinst:mode=0755 \ + --control conffiles:/conffiles \ + --data ... +``` + +Common control files: + +| File | Description | +|------|-------------| +| `control` | Package metadata (required) | +| `preinst` | Script run before installation | +| `postinst` | Script run after installation | +| `prerm` | Script run before removal | +| `postrm` | Script run after removal | +| `conffiles` | List of configuration files | +| `triggers` | Trigger definitions | +| `md5sums` | File checksums (auto-generated) | + +For detailed control file specifications, see the [Debian Policy Manual](https://www.debian.org/doc/debian-policy/ch-controlfields.html). + +### Adding Directories + +Specify a directory as source to include all its contents recursively: + +```bash +debx pack \ + --control control:/control \ + --data ./build/:/opt/myapp \ + -o mypackage.deb +``` + +The directory structure is preserved within the package. -# Open a .deb file -with open("package.deb", "rb") as f: - reader = DebReader(f) +### Complete Example - # Extract control file - control_file = reader.control.extractfile("control") - control_content = control_file.read().decode("utf-8") - print(control_content) - - # List files in the data archive - print(reader.data.getnames()) - - # Extract a file from the data archive - file_data = reader.data.extractfile("usr/bin/example").read() +```bash +# Create control file +cat > control << 'EOF' +Package: myapp +Version: 1.0.0 +Architecture: amd64 +Maintainer: Developer +Description: My Application + A longer description of my application + spanning multiple lines. +Section: utils +Priority: optional +EOF + +# Create postinst script +cat > postinst << 'EOF' +#!/bin/sh +echo "Installation complete!" +EOF + +# Build the package +debx pack \ + --control control:/control \ + --control postinst:/postinst:mode=0755 \ + --data ./bin/myapp:/usr/bin/myapp:mode=0755 \ + --data ./lib/:/usr/lib/myapp \ + --data ./etc/config:/etc/myapp/config \ + -o myapp_1.0.0_amd64.deb ``` -### Creating a Debian Package +## Signing Packages +The `sign` command adds GPG signatures to .deb packages. + +### How It Works + +Signing is a two-step process: + +1. **Extract** the payload from the package +2. **Sign** with GPG and **update** the package with the signature + +### Complete Signing Workflow + +```bash +debx sign --extract mypackage.deb | \ + gpg --armor --detach-sign --output - | \ + debx sign --update mypackage.deb -o mypackage.signed.deb +``` + +This pipeline: +1. Extracts the `control.tar` and `data.tar` from the package +2. Pipes them to GPG for signing +3. Embeds the signature as `_gpgorigin` in the new package + +### Step-by-Step Signing + +If you prefer separate steps: + +```bash +# Extract payload to a file +debx sign --extract mypackage.deb > payload.bin + +# Sign the payload +gpg --armor --detach-sign --output signature.asc payload.bin + +# Update the package with signature +cat signature.asc | debx sign --update mypackage.deb -o mypackage.signed.deb +``` + +### Custom Output Path + +By default, signed packages are named `.signed.deb`. Specify a custom path: + +```bash +debx sign --extract pkg.deb | gpg --armor --detach-sign | \ + debx sign --update pkg.deb -o /path/to/signed-pkg.deb +``` + +--- + +# Developer Guide (Python API) + +## Creating Packages with DebBuilder + +`DebBuilder` is the main class for programmatically creating .deb packages. + +### Basic Usage + + ```python +import os +import tempfile from debx import DebBuilder, Deb822 -# Initialize the builder builder = DebBuilder() -# Create control information +# Add control file (required) control = Deb822({ - "Package": "example", + "Package": "mypackage", "Version": "1.0.0", "Architecture": "all", - "Maintainer": "Example Maintainer ", - "Description": "Example package\n This is an example package created with debx.", - "Section": "utils", - "Priority": "optional" + "Maintainer": "Developer ", + "Description": "Package description", +}) +builder.add_control_entry("control", control.dump()) + +# Add data files +builder.add_data_entry(b"file content", "/path/in/package") + +# Generate the package +deb_content = builder.pack() + +# Write to file +with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "mypackage.deb") + with open(path, "wb") as f: + f.write(deb_content) + assert os.path.exists(path) + assert os.path.getsize(path) > 0 +``` + +### Adding Control Entries + + +```python +from debx import DebBuilder + +builder = DebBuilder() +builder.add_control_entry( + name="control", # Filename in control.tar.gz + content="Package: test\nVersion: 1.0\nArchitecture: all\nMaintainer: Test \nDescription: Test", + mode=0o644, # File permissions (default: 0o644) + mtime=-1, # Modification time (-1 = current time) +) +assert "control" in builder.control_files +``` + +Common control entries: + + +```python +from debx import DebBuilder, Deb822 + +builder = DebBuilder() +control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test package", }) -# Add control file +# Main control file builder.add_control_entry("control", control.dump()) -# Add files to the package -builder.add_data_entry(b"#!/bin/sh\necho 'Hello, world!'\n", "/usr/bin/example", mode=0o755) +# Pre/post installation scripts +builder.add_control_entry("preinst", "#!/bin/sh\necho 'Pre-install'", mode=0o755) +builder.add_control_entry("postinst", "#!/bin/sh\necho 'Post-install'", mode=0o755) -# Add a symlink -builder.add_data_entry(b"", "/usr/bin/example-link", symlink_to="/usr/bin/example") +# Pre/post removal scripts +builder.add_control_entry("prerm", "#!/bin/sh\necho 'Pre-remove'", mode=0o755) +builder.add_control_entry("postrm", "#!/bin/sh\necho 'Post-remove'", mode=0o755) -# Build the package -with open("example.deb", "wb") as f: - f.write(builder.pack()) +# Configuration files list +builder.add_control_entry("conffiles", "/etc/myapp/config\n") + +assert len(builder.control_files) == 6 +``` + +### Adding Data Entries + + +```python +from debx import DebBuilder + +builder = DebBuilder() +builder.add_data_entry( + content=b"binary content", # File content as bytes + name="/usr/bin/myapp", # Absolute path in package + uid=0, # Owner user ID (default: 0) + gid=0, # Owner group ID (default: 0) + mode=0o755, # File permissions (default: 0o644) + mtime=-1, # Modification time (-1 = current time) + symlink_to=None, # Target path for symlinks +) +assert "usr/bin/myapp" in builder.data_files +``` + +### Creating Symlinks + + +```python +from debx import DebBuilder + +builder = DebBuilder() + +# Create the target file +builder.add_data_entry( + b"#!/bin/sh\necho 'Hello'\n", + "/usr/bin/myapp", + mode=0o755 +) + +# Create a symlink to it +builder.add_data_entry( + b"", # Empty content for symlinks + "/usr/bin/myapp-link", + symlink_to="/usr/bin/myapp" +) + +assert "usr/bin/myapp" in builder.data_files +assert "usr/bin/myapp-link" in builder.data_files +``` + +### Reading Files from Disk + + +```python +import os +import tempfile +from pathlib import Path +from debx import DebBuilder + +builder = DebBuilder() + +with tempfile.TemporaryDirectory() as tmp: + # Create test files + build_dir = Path(tmp) / "build" + build_dir.mkdir() + myapp = build_dir / "myapp" + myapp.write_bytes(b"#!/bin/sh\necho hello") + + config_dir = Path(tmp) / "config" + config_dir.mkdir() + config_file = config_dir / "myapp.conf" + config_file.write_bytes(b"key=value") + + # Read a file and add it to the package + binary = myapp.read_bytes() + builder.add_data_entry(binary, "/usr/bin/myapp", mode=0o755) + + # Add configuration file + config = config_file.read_bytes() + builder.add_data_entry(config, "/etc/myapp/myapp.conf", mode=0o644) + + assert "usr/bin/myapp" in builder.data_files + assert "etc/myapp/myapp.conf" in builder.data_files +``` + +### Directory Handling + +Directories are created automatically based on file paths: + + +```python +from debx import DebBuilder + +builder = DebBuilder() + +# This automatically creates /usr, /usr/share, and /usr/share/myapp directories +builder.add_data_entry(b"content", "/usr/share/myapp/data.txt") + +assert len(builder.directories) == 3 +``` + +### MD5 Checksums + +MD5 checksums are automatically calculated and included in `control.tar.gz/md5sums`: + + +```python +from pathlib import PurePosixPath +from debx import DebBuilder + +builder = DebBuilder() +builder.add_data_entry(b"content", "/usr/bin/myapp") + +# Access checksums before packing +assert PurePosixPath("/usr/bin/myapp") in builder.md5sums +assert builder.md5sums[PurePosixPath("/usr/bin/myapp")] == "9a0364b9e99bb480dd25e1f0284c8555" +``` + +## Reading Packages with DebReader + +`DebReader` opens and reads existing .deb packages. + +### Basic Usage + + +```python +import io +from debx import DebBuilder, DebReader, Deb822 + +# First create a package to read +builder = DebBuilder() +control = Deb822({ + "Package": "test-pkg", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test package", +}) +builder.add_control_entry("control", control.dump()) +builder.add_data_entry(b"test content", "/usr/bin/myapp") +builder.add_data_entry(b"config data", "/etc/myapp/config") +deb_content = builder.pack() + +# Now read it +reader = DebReader(io.BytesIO(deb_content)) + +# Access control archive (tarfile.TarFile) +control_names = reader.control.getnames() +assert "control" in control_names +assert "md5sums" in control_names + +# Access data archive (tarfile.TarFile) +data_names = reader.data.getnames() +assert "usr/bin/myapp" in data_names +assert "etc/myapp/config" in data_names +``` + +### Reading the Control File + + +```python +import io +from debx import DebBuilder, DebReader, Deb822 + +# Create a test package +builder = DebBuilder() +control = Deb822({ + "Package": "test-pkg", + "Version": "2.0.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "A test package for reading", +}) +builder.add_control_entry("control", control.dump()) +builder.add_data_entry(b"data", "/usr/bin/test") +deb_content = builder.pack() + +# Read and parse control file +reader = DebReader(io.BytesIO(deb_content)) + +control_member = reader.control.extractfile("control") +control_content = control_member.read().decode("utf-8") + +parsed = Deb822.parse(control_content) + +assert parsed["Package"] == "test-pkg" +assert parsed["Version"] == "2.0.0" +assert "test package" in parsed["Description"] +``` + +### Extracting Data Files + + +```python +import io +import tempfile +from debx import DebBuilder, DebReader, Deb822 + +# Create a test package +builder = DebBuilder() +control = Deb822({ + "Package": "test-pkg", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", +}) +builder.add_control_entry("control", control.dump()) +builder.add_data_entry(b"binary content here", "/usr/bin/myapp") +deb_content = builder.pack() + +reader = DebReader(io.BytesIO(deb_content)) + +# List all files +files = [] +for member in reader.data.getmembers(): + files.append((member.name, member.size)) +assert any("usr/bin/myapp" in f[0] for f in files) + +# Extract a specific file +content = reader.data.extractfile("usr/bin/myapp").read() +assert content == b"binary content here" + +# Extract to directory +with tempfile.TemporaryDirectory() as tmp: + reader.data.extractall(tmp) +``` + +### Reading MD5 Checksums + + +```python +import io +from debx import DebBuilder, DebReader, Deb822 + +# Create a test package +builder = DebBuilder() +control = Deb822({ + "Package": "test-pkg", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", +}) +builder.add_control_entry("control", control.dump()) +builder.add_data_entry(b"file content", "/usr/share/test/file.txt") +deb_content = builder.pack() + +reader = DebReader(io.BytesIO(deb_content)) + +md5sums = reader.control.extractfile("md5sums") +checksums = {} +for line in md5sums.read().decode().splitlines(): + checksum, filepath = line.split(maxsplit=1) + checksums[filepath.strip()] = checksum.strip() + +assert "usr/share/test/file.txt" in checksums ``` -### Working with Debian Control Files +## Working with Control Files (Deb822) + +The `Deb822` class parses and generates Debian control file format (RFC 822 style). + +### Parsing Control Files + ```python from debx import Deb822 -# Parse a control file +# Parse from string control = Deb822.parse(""" Package: example Version: 1.0.0 -Description: Example package - This is a multi-line description - with several paragraphs. +Architecture: all +Description: Short description + This is a longer description that + spans multiple lines. """) -print(control["Package"]) # "example" -print(control["Description"]) # Contains the full multi-line description +assert control["Package"] == "example" +assert control["Version"] == "1.0.0" +assert "Short description" in control["Description"] +assert "longer description" in control["Description"] +``` -# Modify a field -control["Version"] = "1.0.1" +### Creating Control Files -# Add a new field -control["Priority"] = "optional" + +```python +from debx import Deb822 -# Write back to string -print(control.dump()) +# From dictionary +control = Deb822({ + "Package": "mypackage", + "Version": "1.0.0", + "Architecture": "amd64", + "Maintainer": "Name ", + "Depends": "libc6 (>= 2.17), libssl3", + "Description": "Short description\n Long description line 1\n Long description line 2", +}) + +# Generate control file content +content = control.dump() +assert "Package: mypackage" in content +assert "Version: 1.0.0" in content +assert "Architecture: amd64" in content ``` -## Command-Line Interface +### Modifying Control Files -debx includes a command-line interface for packing and unpacking Debian packages. + +```python +from debx import Deb822 -### Packing a Debian Package +existing_content = """ +Package: mypackage +Version: 1.0.0 +Depends: python3 +Suggests: vim +""" -The `pack` command allows you to create a .deb package from files on your system: +control = Deb822.parse(existing_content) -```bash -debx pack \ - --control control:/control \ - preinst:/preinst:mode=0755 \ - --data src/binary:/usr/bin/example:mode=0755 \ - src/config:/etc/example/config \ - src/directory:/opt/example \ - --output example.deb +# Modify fields +control["Version"] = "2.0.0" + +# Add new fields +control["Recommends"] = "nginx" + +# Remove fields +del control["Suggests"] + +# Check field existence +assert "Depends" in control +assert control["Depends"] == "python3" + +# Iterate over fields +keys = list(control) +assert "Package" in keys +assert "Version" in keys + +# Convert to dict +data = control.to_dict() +assert data["Version"] == "2.0.0" +assert "Suggests" not in data ``` -The format for specifying files is: +### Reading from File + + +```python +import tempfile +from pathlib import Path +from debx import Deb822 + +with tempfile.TemporaryDirectory() as tmp: + control_path = Path(tmp) / "control" + control_path.write_text("""Package: test +Version: 1.0 +Architecture: all +Maintainer: Test +Description: Test package +""") + + control = Deb822.from_file(control_path) + assert control["Package"] == "test" + assert control["Version"] == "1.0" +``` + +### Multi-line Fields + +Multi-line values use continuation lines (starting with space): + + +```python +from debx import Deb822 + +control = Deb822({ + "Description": "Short description\n" + "This is line 2 of the long description\n" + "This is line 3 of the long description", +}) + +dumped = control.dump() +assert "Description: Short description" in dumped +assert " This is line 2" in dumped +assert " This is line 3" in dumped ``` -source_path:absolute_destination_path[:modifier1,modifier2,...] + +## Low-Level AR Archive Operations + +For advanced use cases, you can work directly with AR archives. + +### Reading AR Archives + + +```python +import io +from debx import DebBuilder, Deb822, unpack_ar_archive + +# Create a package to read +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"data", "/usr/bin/test") +deb_content = builder.pack() + +# Read the AR archive +files = [] +for ar_file in unpack_ar_archive(io.BytesIO(deb_content)): + files.append({ + "name": ar_file.name, + "size": ar_file.size, + "mode": oct(ar_file.mode), + "uid": ar_file.uid, + "gid": ar_file.gid, + }) + +assert any(f["name"] == "debian-binary" for f in files) +assert any(f["name"] == "control.tar.gz" for f in files) +assert any(f["name"] == "data.tar.bz2" for f in files) ``` -Modifiers is comma-separated list of options: -- `uid=1000` - Set file owner ID (by default is 0) -- `gid=1000` - Set file group ID (by default is 0) -- `mode=0755` - Set file permissions (by default is a source file mode will be kept) -- `mtime=1234567890` - Set file modification time (by default a source file mtime will be kept) +### Creating AR Archives -When specifying a directory, all files within that directory will be included in the package while preserving -the directory structure. + +```python +import io +import tempfile +from pathlib import Path +from debx import ArFile, pack_ar_archive, unpack_ar_archive -Usually deb control files is: +# Create from bytes +file1 = ArFile.from_bytes(b"content", "filename.txt") -* `control` - package metadata in Deb822 format. You can find more information about the control file format in the - [Debian Policy Manual](https://www.debian.org/doc/debian-policy/ch-controlfields.html) -* `preinst` - script to be executed before the package is installed -* `postinst` - script to be executed after the package is installed -* `prerm` - script to be executed before the package is removed -* `postrm` - script to be executed after the package is removed -* `md5sums` - list of files and their md5 checksums (generated automatically) -* `conffiles` - list of configuration files -* `triggers` - list of triggers -* `triggers-file` - list of files for triggers +with tempfile.TemporaryDirectory() as tmp: + # Create a test file + test_file = Path(tmp) / "test.txt" + test_file.write_bytes(b"test file content") -A full list of control files can be found in the -[Debian Policy Manual](https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html). + # Create from file on disk + file2 = ArFile.from_file(test_file, arcname="renamed.txt") -### Unpacking a Debian Package + # Create from file object + data_file = Path(tmp) / "data.bin" + data_file.write_bytes(b"binary data") + with open(data_file, "rb") as f: + file3 = ArFile.from_fp(f, "data.bin") -The `unpack` command extracts a .deb package into a directory: + # Pack into AR archive + archive_content = pack_ar_archive(file1, file2, file3) -```bash -debx unpack package.deb --directory output_dir + # Verify + unpacked = list(unpack_ar_archive(io.BytesIO(archive_content))) + assert len(unpacked) == 3 + assert unpacked[0].name == "filename.txt" + assert unpacked[1].name == "renamed.txt" + assert unpacked[2].name == "data.bin" ``` -This will extract the internal AR archive members and tar archives -(`debian-binary`, `control/`, `data/`) into the specified directory. +### ArFile Properties -### Inspecting a Debian Package + +```python +from debx import ArFile -The `inspect` command allows you to view the contents of a .deb package in different formats: +ar_file = ArFile.from_bytes(b"content", "test.txt") -```bash -debx inspect package.deb # --format=ls (default) +assert ar_file.name == "test.txt" # Filename (max 16 chars) +assert ar_file.size == 7 # Content size in bytes +assert ar_file.content == b"content" # Raw bytes content +assert ar_file.uid == 0 # Owner user ID +assert ar_file.gid == 0 # Owner group ID +assert ar_file.mode == 0o100644 # File mode (permissions) +assert ar_file.mtime > 0 # Modification time (Unix timestamp) +assert ar_file.fp.read() == b"content" # BytesIO file object for content ``` -This will display the contents of the control file and the list of files in the data archive. +--- -You can also specify the format to view the control file in different formats: +# Tutorials + +## Tutorial 1: Creating a Simple Package + +Create a minimal "Hello World" package from scratch. + + +```python +import os +import tempfile +from debx import DebBuilder, Deb822 + +# 1. Initialize builder +builder = DebBuilder() + +# 2. Create control metadata +control = Deb822({ + "Package": "hello-debx", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "Tutorial ", + "Description": "Hello World from debx\n" + "A simple example package created with the debx library.", + "Section": "misc", + "Priority": "optional", +}) +builder.add_control_entry("control", control.dump()) + +# 3. Add executable script +script = b"""#!/bin/sh +echo "Hello from debx!" +echo "This package was created with Python." +""" +builder.add_data_entry(script, "/usr/bin/hello-debx", mode=0o755) + +# 4. Add documentation +readme = b"""Hello Debx Package +================== + +This is a demonstration package created with the debx Python library. + +Usage: hello-debx +""" +builder.add_data_entry(readme, "/usr/share/doc/hello-debx/README") + +# 5. Build and save +with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "hello-debx_1.0.0_all.deb") + with open(path, "wb") as f: + f.write(builder.pack()) + + assert os.path.exists(path) + assert os.path.getsize(path) > 0 + print("Package created: hello-debx_1.0.0_all.deb") +``` + +Install and test: ```bash -debx inspect --format=json package.deb +sudo dpkg -i hello-debx_1.0.0_all.deb +hello-debx +# Output: Hello from debx! ``` -See the `--help` option for more details on available formats. +## Tutorial 2: Extracting and Modifying a Package -### Package signing +Read an existing package, modify it, and create a new version. -The `sign` command allows you to sign a .deb package using GPG: + +```python +import io +import os +import tempfile +from debx import DebReader, DebBuilder, Deb822 -```bash -debx sign --extract mypackage.deb | \ - gpg --armor --detach-sign --output - | \ - debx sign --update mypackage.deb -o mypackage.signed.deb +# First, create an "original" package to modify +original_builder = DebBuilder() +original_control = Deb822({ + "Package": "test-package", + "Version": "1.0.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Original package", +}) +original_builder.add_control_entry("control", original_control.dump()) +original_builder.add_data_entry(b"original binary", "/usr/bin/testapp", mode=0o755) +original_builder.add_data_entry(b"config data", "/etc/testapp/config", mode=0o644) +original_deb = original_builder.pack() + +# 1. Read the original package +reader = DebReader(io.BytesIO(original_deb)) + +# Parse control file +control_content = reader.control.extractfile("control").read().decode() +control = Deb822.parse(control_content) + +# Store all data files +data_files = {} +for member in reader.data.getmembers(): + if member.isfile(): + content = reader.data.extractfile(member).read() + data_files[member.name] = { + "content": content, + "mode": member.mode, + "uid": member.uid, + "gid": member.gid, + } + +# 2. Modify the package +control["Version"] = "1.0.1" # Bump version +control["Description"] = control["Description"] + "\n Modified with debx." + +# 3. Rebuild the package +builder = DebBuilder() + +# Add modified control +builder.add_control_entry("control", control.dump()) + +# Re-add all data files +for path, info in data_files.items(): + builder.add_data_entry( + info["content"], + f"/{path}", # Add leading slash + mode=info["mode"], + uid=info["uid"], + gid=info["gid"], + ) + +# 4. Save the modified package +with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "modified.deb") + with open(path, "wb") as f: + f.write(builder.pack()) + + assert os.path.exists(path) + + # Verify the modification + with open(path, "rb") as f: + verify_reader = DebReader(f) + verify_control = Deb822.parse( + verify_reader.control.extractfile("control").read().decode() + ) + assert verify_control["Version"] == "1.0.1" + assert "Modified with debx" in verify_control["Description"] + +print(f"Modified package: {control['Package']}_{control['Version']}") ``` -This will extract the package, sign it with GPG, and update the package with the signature. -The `--extract` option extracts the package payload and streams it to stdout, which is then signed with GPG. -The `--update` option updates the package with the signature. +## Tutorial 3: Building a Python Application Package + +Package a Python application with configuration and systemd service. + + +```python +import os +import tempfile +from debx import DebBuilder, Deb822 + +def build_python_app_package(output_dir): + builder = DebBuilder() + + # Control file + control = Deb822({ + "Package": "myapp", + "Version": "2.0.0", + "Architecture": "all", + "Maintainer": "DevTeam ", + "Depends": "python3 (>= 3.10)", + "Description": "My Python Application\n" + "A production-ready Python application\n" + "with systemd service integration.", + "Section": "python", + "Priority": "optional", + "Homepage": "https://github.com/company/myapp", + }) + builder.add_control_entry("control", control.dump()) + + # Post-installation script + postinst = """#!/bin/sh +set -e +echo "MyApp installed successfully!" +""" + builder.add_control_entry("postinst", postinst, mode=0o755) + + # Pre-removal script + prerm = """#!/bin/sh +set -e +echo "Removing MyApp..." +""" + builder.add_control_entry("prerm", prerm, mode=0o755) + + # Configuration files list + builder.add_control_entry("conffiles", "/etc/myapp/config.yaml\n") + + # Main application script + app_script = b"""#!/usr/bin/env python3 +import logging +from pathlib import Path + +CONFIG_PATH = Path("/etc/myapp/config.yaml") + +def main(): + print("MyApp is running...") + +if __name__ == "__main__": + main() +""" + builder.add_data_entry(app_script, "/opt/myapp/app.py", mode=0o755) + + # Wrapper script + wrapper = b"""#!/bin/sh +exec /usr/bin/python3 /opt/myapp/app.py "$@" +""" + builder.add_data_entry(wrapper, "/usr/bin/myapp", mode=0o755) + + # Default configuration + config = b"""# MyApp Configuration +server: + host: 0.0.0.0 + port: 8080 + +logging: + level: INFO +""" + builder.add_data_entry(config, "/etc/myapp/config.yaml", mode=0o644) + + # Systemd service file + service = b"""[Unit] +Description=MyApp Python Application +After=network.target -## License +[Service] +Type=simple +User=root +ExecStart=/usr/bin/myapp +Restart=on-failure + +[Install] +WantedBy=multi-user.target +""" + builder.add_data_entry(service, "/lib/systemd/system/myapp.service", mode=0o644) + + # Build package + package_name = f"{control['Package']}_{control['Version']}_all.deb" + package_path = os.path.join(output_dir, package_name) + with open(package_path, "wb") as f: + f.write(builder.pack()) + + print(f"Built: {package_name}") + return package_path + +# Test the function +with tempfile.TemporaryDirectory() as tmp: + path = build_python_app_package(tmp) + assert os.path.exists(path) + assert os.path.getsize(path) > 0 +``` + +--- + +# API Reference + +## DebBuilder + +| Method | Description | +|--------|-------------| +| `add_control_entry(name, content, mode=0o644, mtime=-1)` | Add a file to control.tar.gz | +| `add_data_entry(content, name, uid=0, gid=0, mode=0o644, mtime=-1, symlink_to=None)` | Add a file to data.tar.bz2 | +| `pack()` | Build and return the .deb package as bytes | +| `create_control_tar()` | Generate control.tar.gz content | +| `create_data_tar()` | Generate data.tar.bz2 content | + +**Properties:** +- `md5sums: dict[PurePosixPath, str]` - MD5 checksums of data files +- `data_files: dict` - Data entries to be packed +- `control_files: dict` - Control entries to be packed +- `directories: set` - Directories that will be created + +## DebReader + +| Attribute | Type | Description | +|-----------|------|-------------| +| `control` | `tarfile.TarFile` | Control archive (control.tar.gz) | +| `data` | `tarfile.TarFile` | Data archive (data.tar.*) | + +## Deb822 + +| Method | Description | +|--------|-------------| +| `parse(text)` | Parse Deb822 format string | +| `from_file(path)` | Parse from file path | +| `dump()` | Generate Deb822 format string | +| `to_dict()` | Convert to dictionary | + +Implements `MutableMapping[str, Any]` - supports `[]`, `in`, `del`, `len()`, iteration. + +## AR Archive Functions + +| Function | Description | +|----------|-------------| +| `pack_ar_archive(*files)` | Create AR archive from ArFile objects | +| `unpack_ar_archive(fp)` | Iterate ArFile objects from archive | + +## ArFile + +| Method | Description | +|--------|-------------| +| `from_bytes(data, name, **kwargs)` | Create from bytes | +| `from_file(path, arcname="")` | Create from file path | +| `from_fp(fp, name, **kwargs)` | Create from file object | +| `dump()` | Serialize to AR format | + +**Attributes:** `name`, `size`, `content`, `uid`, `gid`, `mode`, `mtime`, `fp` + +## Exceptions + +| Exception | Description | +|-----------|-------------| +| `ARFileError` | Base exception for AR operations | +| `EmptyHeaderError` | AR header is empty | +| `TruncatedHeaderError` | AR header is incomplete | +| `TruncatedDataError` | AR data is incomplete | + +--- + +# License [MIT License](COPYING) -## Contributing +# Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. -Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request diff --git a/pyproject.toml b/pyproject.toml index a4db5b9..eaf9244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "debx" -version = "0.2.10" +version = "0.2.11" description = "Minimal Python library to programmatically construct Debian .deb packages" readme = "README.md" authors = [ @@ -31,6 +31,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ "coveralls>=4.0.2", + "markdown-pytest>=0.3.2", "pytest>=9.0.2", "pytest-cov>=7.0.0", ] diff --git a/uv.lock b/uv.lock index 6ed07f8..9a9323d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -235,6 +244,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "coveralls" }, + { name = "markdown-pytest" }, { name = "pytest" }, { name = "pytest-cov" }, ] @@ -244,6 +254,7 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "coveralls", specifier = ">=4.0.2" }, + { name = "markdown-pytest", specifier = ">=0.3.2" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, ] @@ -284,6 +295,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown-pytest" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest-subtests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/c8/1da6e4461b88418469a1349e0da0973fd6e0e00c08a03bacc53a0553cb00/markdown_pytest-0.3.2.tar.gz", hash = "sha256:ddb1b746e18fbdaab97bc2058b90c0b3d71063c8265a88a8c755f433f94a5e12", size = 8900, upload-time = "2024-05-07T14:08:33.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/a1/1592c58d441f7385bdcec53b06a4865a188c00f19abeff955a129e99f7c4/markdown_pytest-0.3.2-py3-none-any.whl", hash = "sha256:2faabe7bfba232b2b2075d6b552580d370733c5ff77934f41bcfcb960b06d09f", size = 9526, upload-time = "2024-05-07T14:08:32.26Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -343,6 +366,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + [[package]] name = "requests" version = "2.32.5"