From 12f32f602c21c3fd0f755f09780e0e4d8e14cef2 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 14:29:16 +0100 Subject: [PATCH 1/3] Rework docs --- README.md | 1100 +++++++++++++++++++++++++++++++++++++++++++----- pyproject.toml | 1 + uv.lock | 36 ++ 3 files changed, 1024 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 0bcb9b0..85f15f8 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,1037 @@ 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 +from debx import DebBuilder, Deb822 -# Open a .deb file -with open("package.deb", "rb") as f: - reader = DebReader(f) +# Create a new package +builder = DebBuilder() - # 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() +# 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 open("hello-world_1.0.0_all.deb", "wb") as f: + f.write(builder.pack()) +``` + +--- + +# 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 ``` -### Creating a Debian Package +### 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. + +### Complete Example + +```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 +``` + +## 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 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 open("mypackage.deb", "wb") as f: + f.write(deb_content) +``` + +### Adding Control Entries + +```python +builder.add_control_entry( + name="control", # Filename in control.tar.gz + content="Package: ...", # String or bytes content + mode=0o644, # File permissions (default: 0o644) + mtime=-1, # Modification time (-1 = current time) +) +``` + +Common control entries: -# Add control file +```python +# 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") +``` + +### Adding Data Entries + +```python +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 +) +``` + +### Creating Symlinks + +```python +# 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" +) +``` + +### Reading Files from Disk + +```python +from pathlib import Path + +# Read a file and add it to the package +binary = Path("./build/myapp").read_bytes() +builder.add_data_entry(binary, "/usr/bin/myapp", mode=0o755) + +# Add configuration file +config = Path("./config/myapp.conf").read_bytes() +builder.add_data_entry(config, "/etc/myapp/myapp.conf", mode=0o644) +``` + +### Directory Handling + +Directories are created automatically based on file paths: + +```python +# This automatically creates /usr, /usr/share, and /usr/share/myapp directories +builder.add_data_entry(b"content", "/usr/share/myapp/data.txt") +``` + +### MD5 Checksums + +MD5 checksums are automatically calculated and included in `control.tar.gz/md5sums`: + +```python +builder.add_data_entry(b"content", "/usr/bin/myapp") + +# Access checksums before packing +print(builder.md5sums) +# {PurePosixPath('/usr/bin/myapp'): 'd41d8cd98f00b204e9800998ecf8427e'} +``` + +## Reading Packages with DebReader + +`DebReader` opens and reads existing .deb packages. + +### Basic Usage + +```python +from debx import DebReader, Deb822 + +with open("package.deb", "rb") as f: + reader = DebReader(f) + + # Access control archive (tarfile.TarFile) + print(reader.control.getnames()) + # ['control', 'md5sums', 'postinst', ...] + + # Access data archive (tarfile.TarFile) + print(reader.data.getnames()) + # ['usr/bin/myapp', 'etc/myapp/config', ...] +``` + +### Reading the Control File + +```python +with open("package.deb", "rb") as f: + reader = DebReader(f) + + # Extract and parse control file + control_member = reader.control.extractfile("control") + control_content = control_member.read().decode("utf-8") + + control = Deb822.parse(control_content) + + print(f"Package: {control['Package']}") + print(f"Version: {control['Version']}") + print(f"Description: {control['Description']}") +``` + +### Extracting Data Files + +```python +with open("package.deb", "rb") as f: + reader = DebReader(f) + + # List all files + for member in reader.data.getmembers(): + print(f"{member.name} ({member.size} bytes)") + + # Extract a specific file + content = reader.data.extractfile("usr/bin/myapp").read() + + # Extract to directory + reader.data.extractall("/tmp/extracted") +``` + +### Reading MD5 Checksums + +```python +with open("package.deb", "rb") as f: + reader = DebReader(f) + + md5sums = reader.control.extractfile("md5sums") + for line in md5sums.read().decode().splitlines(): + checksum, filepath = line.split(maxsplit=1) + print(f"{filepath}: {checksum}") ``` -### 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 +print(control["Package"]) # "example" +print(control["Version"]) # "1.0.0" +print(control["Description"]) # "Short description\nThis is a longer..." +``` -# 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() +print(content) ``` -## Command-Line Interface +Output: +``` +Package: mypackage +Version: 1.0.0 +Architecture: amd64 +Maintainer: Name +Depends: libc6 (>= 2.17), libssl3 +Description: Short description + Long description line 1 + Long description line 2 +``` -debx includes a command-line interface for packing and unpacking Debian packages. +### Modifying Control Files -### Packing a Debian Package +```python +control = Deb822.parse(existing_content) -The `pack` command allows you to create a .deb package from files on your system: +# Modify fields +control["Version"] = "2.0.0" -```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 +# Add new fields +control["Recommends"] = "nginx" + +# Remove fields +del control["Suggests"] + +# Check field existence +if "Depends" in control: + print(control["Depends"]) + +# Iterate over fields +for key in control: + print(f"{key}: {control[key]}") + +# Convert to dict +data = control.to_dict() ``` -The format for specifying files is: +### Reading from File + +```python +from debx import Deb822 + +control = Deb822.from_file("/path/to/control") ``` -source_path:absolute_destination_path[:modifier1,modifier2,...] + +### Multi-line Fields + +Multi-line values use continuation lines (starting with space): + +```python +control = Deb822({ + "Description": "Short description\n" + "This is line 2 of the long description\n" + "This is line 3 of the long description", +}) + +print(control.dump()) +# Description: Short description +# This is line 2 of the long description +# This is line 3 of the long description ``` -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) +## Low-Level AR Archive Operations -When specifying a directory, all files within that directory will be included in the package while preserving -the directory structure. +For advanced use cases, you can work directly with AR archives. -Usually deb control files is: +### Reading AR Archives -* `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 +```python +from debx import unpack_ar_archive -A full list of control files can be found in the -[Debian Policy Manual](https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html). +with open("package.deb", "rb") as f: + for ar_file in unpack_ar_archive(f): + print(f"Name: {ar_file.name}") + print(f"Size: {ar_file.size}") + print(f"Mode: {oct(ar_file.mode)}") + print(f"UID/GID: {ar_file.uid}/{ar_file.gid}") + print(f"MTime: {ar_file.mtime}") + # Access content + content = ar_file.content +``` -### Unpacking a Debian Package +### Creating AR Archives -The `unpack` command extracts a .deb package into a directory: +```python +from debx import ArFile, pack_ar_archive -```bash -debx unpack package.deb --directory output_dir +# Create from bytes +file1 = ArFile.from_bytes(b"content", "filename.txt") + +# Create from file on disk +file2 = ArFile.from_file("/path/to/file", arcname="renamed.txt") + +# Create from file object +with open("data.bin", "rb") as f: + file3 = ArFile.from_fp(f, "data.bin") + +# Pack into AR archive +archive_content = pack_ar_archive(file1, file2, file3) + +with open("archive.ar", "wb") as f: + f.write(archive_content) +``` + +### ArFile Properties + +```python +ar_file = ArFile.from_bytes(b"content", "test.txt") + +ar_file.name # Filename (max 16 chars) +ar_file.size # Content size in bytes +ar_file.content # Raw bytes content +ar_file.uid # Owner user ID +ar_file.gid # Owner group ID +ar_file.mode # File mode (permissions) +ar_file.mtime # Modification time (Unix timestamp) +ar_file.fp # BytesIO file object for content ``` -This will extract the internal AR archive members and tar archives -(`debian-binary`, `control/`, `data/`) into the specified directory. +--- + +# Tutorials + +## Tutorial 1: Creating a Simple Package + +Create a minimal "Hello World" package from scratch. + +```python +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 +================== -### Inspecting a Debian Package +This is a demonstration package created with the debx Python library. -The `inspect` command allows you to view the contents of a .deb package in different formats: +Usage: hello-debx +""" +builder.add_data_entry(readme, "/usr/share/doc/hello-debx/README") + +# 5. Build and save +with open("hello-debx_1.0.0_all.deb", "wb") as f: + f.write(builder.pack()) + +print("Package created: hello-debx_1.0.0_all.deb") +``` + +Install and test: ```bash -debx inspect package.deb # --format=ls (default) +sudo dpkg -i hello-debx_1.0.0_all.deb +hello-debx +# Output: Hello from debx! ``` -This will display the contents of the control file and the list of files in the data archive. +## Tutorial 2: Extracting and Modifying a Package -You can also specify the format to view the control file in different formats: +Read an existing package, modify it, and create a new version. -```bash -debx inspect --format=json package.deb +```python +from debx import DebReader, DebBuilder, Deb822 + +# 1. Read the original package +with open("original.deb", "rb") as f: + reader = DebReader(f) + + # 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 open("modified.deb", "wb") as f: + f.write(builder.pack()) + +print(f"Modified package: {control['Package']}_{control['Version']}") ``` -See the `--help` option for more details on available formats. +## Tutorial 3: Building a Python Application Package -### Package signing +Package a Python application with configuration and systemd service. -The `sign` command allows you to sign a .deb package using GPG: +```python +from debx import DebBuilder, Deb822 +from pathlib import Path +import stat -```bash -debx sign --extract mypackage.deb | \ - gpg --armor --detach-sign --output - | \ - debx sign --update mypackage.deb -o mypackage.signed.deb +def build_python_app_package(): + builder = DebBuilder() + + # Control file + control = Deb822({ + "Package": "myapp", + "Version": "2.0.0", + "Architecture": "all", + "Maintainer": "DevTeam ", + "Depends": "python3 (>= 3.10), python3-pip", + "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 + +# Create application user +if ! getent passwd myapp > /dev/null; then + useradd --system --no-create-home --shell /usr/sbin/nologin myapp +fi + +# Set ownership +chown -R myapp:myapp /opt/myapp +chown -R myapp:myapp /var/log/myapp + +# Enable and start service +systemctl daemon-reload +systemctl enable myapp +systemctl start myapp + +echo "MyApp installed successfully!" +""" + builder.add_control_entry("postinst", postinst, mode=0o755) + + # Pre-removal script + prerm = """#!/bin/sh +set -e + +# Stop service before removal +if systemctl is-active --quiet myapp; then + systemctl stop myapp +fi +systemctl disable myapp || true +""" + builder.add_control_entry("prerm", prerm, mode=0o755) + + # Configuration files list + builder.add_control_entry("conffiles", "/etc/myapp/config.yaml\n") + + # Application files + # Main application script + app_script = b"""#!/usr/bin/env python3 +import yaml +import logging +from pathlib import Path + +CONFIG_PATH = Path("/etc/myapp/config.yaml") +LOG_PATH = Path("/var/log/myapp/app.log") + +def main(): + logging.basicConfig(filename=LOG_PATH, level=logging.INFO) + config = yaml.safe_load(CONFIG_PATH.read_text()) + logging.info(f"Starting MyApp with config: {config}") + # Application logic here + 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 + +features: + debug_mode: false +""" + builder.add_data_entry(config, "/etc/myapp/config.yaml", mode=0o644) + + # Systemd service file + service = b"""[Unit] +Description=MyApp Python Application +After=network.target + +[Service] +Type=simple +User=myapp +Group=myapp +ExecStart=/usr/bin/myapp +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +""" + builder.add_data_entry(service, "/lib/systemd/system/myapp.service", mode=0o644) + + # Create log directory placeholder + builder.add_data_entry(b"", "/var/log/myapp/.keep", mode=0o644) + + # Build package + package_name = f"{control['Package']}_{control['Version']}_all.deb" + with open(package_name, "wb") as f: + f.write(builder.pack()) + + print(f"Built: {package_name}") + return package_name + +if __name__ == "__main__": + build_python_app_package() ``` -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. +--- + +# 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 -## License +| 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..166956d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 0816d7fe8320464296b831e846c1ce5b7edf8dd4 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 14:35:45 +0100 Subject: [PATCH 2/3] testable examples --- README.md | 562 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 391 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 85f15f8..3fabd42 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,10 @@ debx pack \ ### 30-Second Python Example + ```python +import os +import tempfile from debx import DebBuilder, Deb822 # Create a new package @@ -96,8 +99,11 @@ builder.add_data_entry( ) # Write the package -with open("hello-world_1.0.0_all.deb", "wb") as f: - f.write(builder.pack()) +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) ``` --- @@ -392,7 +398,10 @@ debx sign --extract pkg.deb | gpg --armor --detach-sign | \ ### Basic Usage + ```python +import os +import tempfile from debx import DebBuilder, Deb822 builder = DebBuilder() @@ -414,24 +423,45 @@ builder.add_data_entry(b"file content", "/path/in/package") deb_content = builder.pack() # Write to file -with open("mypackage.deb", "wb") as f: - f.write(deb_content) +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: ...", # String or bytes content + 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", +}) + # Main control file builder.add_control_entry("control", control.dump()) @@ -445,11 +475,17 @@ builder.add_control_entry("postrm", "#!/bin/sh\necho 'Post-remove'", mode=0o755) # 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 @@ -459,11 +495,17 @@ builder.add_data_entry( 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", @@ -477,41 +519,77 @@ builder.add_data_entry( "/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) -# Read a file and add it to the package -binary = Path("./build/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) -# Add configuration file -config = Path("./config/myapp.conf").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 -print(builder.md5sums) -# {PurePosixPath('/usr/bin/myapp'): 'd41d8cd98f00b204e9800998ecf8427e'} +assert PurePosixPath("/usr/bin/myapp") in builder.md5sums +assert builder.md5sums[PurePosixPath("/usr/bin/myapp")] == "9a0364b9e99bb480dd25e1f0284c8555" ``` ## Reading Packages with DebReader @@ -520,65 +598,139 @@ print(builder.md5sums) ### Basic Usage + ```python -from debx import DebReader, Deb822 +import io +from debx import DebBuilder, DebReader, Deb822 -with open("package.deb", "rb") as f: - reader = DebReader(f) +# 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) - print(reader.control.getnames()) - # ['control', 'md5sums', 'postinst', ...] +# 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) - print(reader.data.getnames()) - # ['usr/bin/myapp', 'etc/myapp/config', ...] +# 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 -with open("package.deb", "rb") as f: - reader = DebReader(f) +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() - # Extract and parse control file - control_member = reader.control.extractfile("control") - control_content = control_member.read().decode("utf-8") +# Read and parse control file +reader = DebReader(io.BytesIO(deb_content)) - control = Deb822.parse(control_content) +control_member = reader.control.extractfile("control") +control_content = control_member.read().decode("utf-8") - print(f"Package: {control['Package']}") - print(f"Version: {control['Version']}") - print(f"Description: {control['Description']}") +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 -with open("package.deb", "rb") as f: - reader = DebReader(f) +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() - # List all files - for member in reader.data.getmembers(): - print(f"{member.name} ({member.size} bytes)") +reader = DebReader(io.BytesIO(deb_content)) - # Extract a specific file - content = reader.data.extractfile("usr/bin/myapp").read() +# 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 to directory - reader.data.extractall("/tmp/extracted") +# 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 -with open("package.deb", "rb") as f: - reader = DebReader(f) +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() - md5sums = reader.control.extractfile("md5sums") - for line in md5sums.read().decode().splitlines(): - checksum, filepath = line.split(maxsplit=1) - print(f"{filepath}: {checksum}") +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 Control Files (Deb822) @@ -587,6 +739,7 @@ The `Deb822` class parses and generates Debian control file format (RFC 822 styl ### Parsing Control Files + ```python from debx import Deb822 @@ -600,13 +753,15 @@ Description: Short description spans multiple lines. """) -print(control["Package"]) # "example" -print(control["Version"]) # "1.0.0" -print(control["Description"]) # "Short description\nThis is a longer..." +assert control["Package"] == "example" +assert control["Version"] == "1.0.0" +assert "Short description" in control["Description"] +assert "longer description" in control["Description"] ``` ### Creating Control Files + ```python from debx import Deb822 @@ -622,24 +777,24 @@ control = Deb822({ # Generate control file content content = control.dump() -print(content) -``` - -Output: -``` -Package: mypackage -Version: 1.0.0 -Architecture: amd64 -Maintainer: Name -Depends: libc6 (>= 2.17), libssl3 -Description: Short description - Long description line 1 - Long description line 2 +assert "Package: mypackage" in content +assert "Version: 1.0.0" in content +assert "Architecture: amd64" in content ``` ### Modifying Control Files + ```python +from debx import Deb822 + +existing_content = """ +Package: mypackage +Version: 1.0.0 +Depends: python3 +Suggests: vim +""" + control = Deb822.parse(existing_content) # Modify fields @@ -652,40 +807,60 @@ control["Recommends"] = "nginx" del control["Suggests"] # Check field existence -if "Depends" in control: - print(control["Depends"]) +assert "Depends" in control +assert control["Depends"] == "python3" # Iterate over fields -for key in control: - print(f"{key}: {control[key]}") +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 ``` ### Reading from File + ```python +import tempfile +from pathlib import Path from debx import Deb822 -control = Deb822.from_file("/path/to/control") +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", }) -print(control.dump()) -# Description: Short description -# This is line 2 of the long description -# 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 ``` ## Low-Level AR Archive Operations @@ -694,55 +869,93 @@ For advanced use cases, you can work directly with AR archives. ### Reading AR Archives + ```python -from debx import unpack_ar_archive - -with open("package.deb", "rb") as f: - for ar_file in unpack_ar_archive(f): - print(f"Name: {ar_file.name}") - print(f"Size: {ar_file.size}") - print(f"Mode: {oct(ar_file.mode)}") - print(f"UID/GID: {ar_file.uid}/{ar_file.gid}") - print(f"MTime: {ar_file.mtime}") - # Access content - content = ar_file.content +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) ``` ### Creating AR Archives + ```python -from debx import ArFile, pack_ar_archive +import io +import tempfile +from pathlib import Path +from debx import ArFile, pack_ar_archive, unpack_ar_archive # Create from bytes file1 = ArFile.from_bytes(b"content", "filename.txt") -# Create from file on disk -file2 = ArFile.from_file("/path/to/file", arcname="renamed.txt") - -# Create from file object -with open("data.bin", "rb") as f: - file3 = ArFile.from_fp(f, "data.bin") - -# Pack into AR archive -archive_content = pack_ar_archive(file1, file2, file3) - -with open("archive.ar", "wb") as f: - f.write(archive_content) +with tempfile.TemporaryDirectory() as tmp: + # Create a test file + test_file = Path(tmp) / "test.txt" + test_file.write_bytes(b"test file content") + + # Create from file on disk + file2 = ArFile.from_file(test_file, arcname="renamed.txt") + + # 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") + + # Pack into AR archive + archive_content = pack_ar_archive(file1, file2, file3) + + # 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" ``` ### ArFile Properties + ```python +from debx import ArFile + ar_file = ArFile.from_bytes(b"content", "test.txt") -ar_file.name # Filename (max 16 chars) -ar_file.size # Content size in bytes -ar_file.content # Raw bytes content -ar_file.uid # Owner user ID -ar_file.gid # Owner group ID -ar_file.mode # File mode (permissions) -ar_file.mtime # Modification time (Unix timestamp) -ar_file.fp # BytesIO file object for content +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 ``` --- @@ -753,7 +966,10 @@ ar_file.fp # BytesIO file object for content Create a minimal "Hello World" package from scratch. + ```python +import os +import tempfile from debx import DebBuilder, Deb822 # 1. Initialize builder @@ -790,10 +1006,14 @@ Usage: hello-debx builder.add_data_entry(readme, "/usr/share/doc/hello-debx/README") # 5. Build and save -with open("hello-debx_1.0.0_all.deb", "wb") as f: - f.write(builder.pack()) +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()) -print("Package created: hello-debx_1.0.0_all.deb") + assert os.path.exists(path) + assert os.path.getsize(path) > 0 + print("Package created: hello-debx_1.0.0_all.deb") ``` Install and test: @@ -808,28 +1028,45 @@ hello-debx Read an existing package, modify it, and create a new version. + ```python +import io +import os +import tempfile from debx import DebReader, DebBuilder, Deb822 +# 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 -with open("original.deb", "rb") as f: - reader = DebReader(f) - - # 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, - } +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 @@ -852,8 +1089,21 @@ for path, info in data_files.items(): ) # 4. Save the modified package -with open("modified.deb", "wb") as f: - f.write(builder.pack()) +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']}") ``` @@ -862,12 +1112,13 @@ print(f"Modified package: {control['Package']}_{control['Version']}") Package a Python application with configuration and systemd service. + ```python +import os +import tempfile from debx import DebBuilder, Deb822 -from pathlib import Path -import stat -def build_python_app_package(): +def build_python_app_package(output_dir): builder = DebBuilder() # Control file @@ -876,7 +1127,7 @@ def build_python_app_package(): "Version": "2.0.0", "Architecture": "all", "Maintainer": "DevTeam ", - "Depends": "python3 (>= 3.10), python3-pip", + "Depends": "python3 (>= 3.10)", "Description": "My Python Application\n" "A production-ready Python application\n" "with systemd service integration.", @@ -889,21 +1140,6 @@ def build_python_app_package(): # Post-installation script postinst = """#!/bin/sh set -e - -# Create application user -if ! getent passwd myapp > /dev/null; then - useradd --system --no-create-home --shell /usr/sbin/nologin myapp -fi - -# Set ownership -chown -R myapp:myapp /opt/myapp -chown -R myapp:myapp /var/log/myapp - -# Enable and start service -systemctl daemon-reload -systemctl enable myapp -systemctl start myapp - echo "MyApp installed successfully!" """ builder.add_control_entry("postinst", postinst, mode=0o755) @@ -911,33 +1147,21 @@ echo "MyApp installed successfully!" # Pre-removal script prerm = """#!/bin/sh set -e - -# Stop service before removal -if systemctl is-active --quiet myapp; then - systemctl stop myapp -fi -systemctl disable myapp || true +echo "Removing MyApp..." """ builder.add_control_entry("prerm", prerm, mode=0o755) # Configuration files list builder.add_control_entry("conffiles", "/etc/myapp/config.yaml\n") - # Application files # Main application script app_script = b"""#!/usr/bin/env python3 -import yaml import logging from pathlib import Path CONFIG_PATH = Path("/etc/myapp/config.yaml") -LOG_PATH = Path("/var/log/myapp/app.log") def main(): - logging.basicConfig(filename=LOG_PATH, level=logging.INFO) - config = yaml.safe_load(CONFIG_PATH.read_text()) - logging.info(f"Starting MyApp with config: {config}") - # Application logic here print("MyApp is running...") if __name__ == "__main__": @@ -959,9 +1183,6 @@ server: logging: level: INFO - -features: - debug_mode: false """ builder.add_data_entry(config, "/etc/myapp/config.yaml", mode=0o644) @@ -972,30 +1193,29 @@ After=network.target [Service] Type=simple -User=myapp -Group=myapp +User=root ExecStart=/usr/bin/myapp Restart=on-failure -RestartSec=5 [Install] WantedBy=multi-user.target """ builder.add_data_entry(service, "/lib/systemd/system/myapp.service", mode=0o644) - # Create log directory placeholder - builder.add_data_entry(b"", "/var/log/myapp/.keep", mode=0o644) - # Build package package_name = f"{control['Package']}_{control['Version']}_all.deb" - with open(package_name, "wb") as f: + 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_name + return package_path -if __name__ == "__main__": - build_python_app_package() +# 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 ``` --- From 61f07569e1588bc53c65682673282d6253bb5099 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 14:36:43 +0100 Subject: [PATCH 3/3] bump to 0.2.11 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 166956d..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 = [