Metadata-Version: 2.4
Name: oaknut-dfs
Version: 0.1.2
Summary: Python library for handling Acorn DFS disc images (SSD/DSD format)
Author-email: Robert Smallshire <robert@smallshire.org.uk>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: click>=8.1.7
Requires-Dist: typename>=1.0.4
Dynamic: license-file

# oaknut-dfs

[![PyPI version](https://img.shields.io/pypi/v/oaknut-dfs)](https://pypi.org/project/oaknut-dfs/)
[![CI](https://github.com/rob-smallshire/oaknut-dfs/actions/workflows/tests.yml/badge.svg)](https://github.com/rob-smallshire/oaknut-dfs/actions/workflows/tests.yml)
[![Python versions](https://img.shields.io/pypi/pyversions/oaknut-dfs)](https://pypi.org/project/oaknut-dfs/)
[![License: MIT](https://img.shields.io/pypi/l/oaknut-dfs)](https://github.com/rob-smallshire/oaknut-dfs/blob/master/LICENSE)

A Python library for reading and writing
[Acorn DFS](https://en.wikipedia.org/wiki/Disc_Filing_System) (Disc Filing
System) disc images in SSD and DSD formats, as used by the
[BBC Micro](https://en.wikipedia.org/wiki/BBC_Micro) and
[Acorn Electron](https://en.wikipedia.org/wiki/Acorn_Electron).

## The problem

Software for the BBC Micro and related Acorn 8-bit computers is commonly
distributed as disc images in SSD (single-sided) and DSD (double-sided)
formats. These images contain a DFS catalogue structure that encodes
filenames, load addresses, execution addresses, and file attributes in a
format specific to the Acorn Disc Filing System.

Working with these images programmatically --- extracting files, inspecting
metadata, creating new images, or modifying existing ones --- requires
understanding the low-level catalogue format and sector layout. oaknut-dfs
provides a Pythonic API that handles these details, letting you work with
DFS disc images using familiar Python patterns.

## Supported formats

- **Acorn DFS**: 40-track and 80-track, single-sided (SSD) and double-sided (DSD)
- **Watford DFS**: Extended catalogue supporting up to 62 files (format constants defined)
- **DSD interleaving**: Both interleaved and sequential double-sided layouts
- **Acorn character encoding**: Custom codec for the BBC Micro character set (`£`, `¦`)

## Prerequisites

oaknut-dfs requires only [`uv`](https://docs.astral.sh/uv/). `uv` handles
Python installation, dependency resolution, and virtual environments
automatically --- you do not need to install anything else by hand.

### Installing uv

**macOS (Homebrew):**

```
brew install uv
```

**Linux / macOS (standalone installer):**

```
curl -LsSf https://astral.sh/uv/install.sh | sh
```

**Windows:**

```
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/)
for other methods including pip, pipx, Cargo, Conda, Winget, and Scoop.

## Installation

### As a library dependency

```
uv add oaknut-dfs
```

### For development

```
uv sync
```

## Usage

### Creating a DFS instance

```python
from oaknut_dfs import DFS, ACORN_DFS_40T_SINGLE_SIDED

# Create a 40-track single-sided disc image (102,400 bytes)
buffer = bytearray(102400)
# ... initialise catalogue sectors ...

dfs = DFS.from_buffer(memoryview(buffer), ACORN_DFS_40T_SINGLE_SIDED)
print(dfs.title)   # 'DEMO'
print(repr(dfs))   # DFS(title='DEMO', files=0, free_sectors=398)
```

### Saving and loading files

```python
# Save files with load and execution addresses
dfs.save("$.HELLO", b"Hello, World!", load_address=0x1200, exec_address=0x1200)
dfs.save("$.README", b"oaknut-dfs demo disc")

# Load a file back
data = dfs.load("$.HELLO")
print(data)   # b'Hello, World!'
```

### Listing files

```python
for entry in dfs.files:
    lock = "L" if entry.locked else " "
    print(f"{lock} {entry.path:10s}  {entry.load_address:06X}  {entry.exec_address:06X}  {entry.length:06X}")
#   $.HELLO     001200  001200  00000D
#   $.README    000000  000000  000014
# L $.PROG      003000  003000  000200
```

### File information

```python
info = dfs.get_file_info("$.HELLO")
print(info.name)            # '$.HELLO'
print(hex(info.load_address))  # 0x1200
print(hex(info.exec_address))  # 0x1200
print(info.length)          # 13
print(info.locked)          # False
print(info.start_sector)    # 2
print(info.sectors)         # 1
```

### Disc information

```python
print(dfs.info)
# {
#     'title': 'DEMO',
#     'num_files': 1,
#     'total_sectors': 400,
#     'free_sectors': 397,
#     'boot_option': 0,
# }
```

### Pythonic interface

```python
# Check if a file exists
print("$.HELLO" in dfs)   # True
print("$.NOPE" in dfs)    # False

# Number of files
print(len(dfs))            # 2

# Iterate over files
for entry in dfs:
    print(entry.path)
# $.HELLO
# $.README
```

### Double-sided discs (DSD)

```python
from oaknut_dfs import ACORN_DFS_40T_DOUBLE_SIDED_INTERLEAVED

# DSD images contain two independent sides, each with its own catalogue.
# This mirrors the BBC Micro, where double-sided discs were accessed as
# separate drives using *DRIVE 0 and *DRIVE 2.

buffer = bytearray(204800)  # 40-track double-sided
# ... initialise catalogue sectors for both sides ...

# Access each side independently
dfs0 = DFS.from_buffer(memoryview(buffer), ACORN_DFS_40T_DOUBLE_SIDED_INTERLEAVED, side=0)
dfs1 = DFS.from_buffer(memoryview(buffer), ACORN_DFS_40T_DOUBLE_SIDED_INTERLEAVED, side=1)

# Each side has its own title, files, and catalogue
print(dfs0.title)   # 'SIDE ZERO'
print(dfs1.title)   # 'SIDE ONE'

# Files on one side are not visible from the other
print("$.FILE0" in dfs0)   # True
print("$.FILE0" in dfs1)   # False
```

## Development

After cloning, install the pre-commit hooks:

```
uv run --group dev pre-commit install
```

### Running the tests

```
uv run --group test pytest tests/ -v
```

## Architecture

The library uses a layered architecture with dependencies flowing downward:

1. **Sector access** (`surface.py`, `sectors_view.py`) --- operates on buffers
   to convert logical sector numbers to physical byte offsets. Handles disc
   geometry and interleaving schemes.

2. **Catalogue management** (`catalogue.py`, `acorn_dfs_catalogue.py`,
   `watford_dfs_catalogue.py`) --- parses and manages the DFS catalogue
   structure in sectors 0--1. Supports Acorn DFS (31 files) and Watford DFS
   (62 files).

3. **DFS API** (`dfs.py`) --- user-facing Pythonic interface mirroring BBC
   Micro DFS star commands. Supports file operations, disc metadata, iteration,
   and the `in` operator.

## References

### Format specifications

- [Acorn DFS disc format](https://beebwiki.mdfs.net/Acorn_DFS_disc_format) ---
  BeebWiki specification for the Acorn DFS catalogue layout.
- [Disc Filing System](https://en.wikipedia.org/wiki/Disc_Filing_System) ---
  Wikipedia overview of DFS and its variants.
- [INF file format](https://beebwiki.mdfs.net/INF_file_format) ---
  BeebWiki specification for the `.inf` sidecar metadata format.

### Related tools and projects

- [oaknut-zip](https://github.com/rob-smallshire/oaknut-zip) ---
  Sister project for extracting ZIP files containing Acorn metadata.

### Forum discussions

- [Stardot forum: DFS format](https://stardot.org.uk/forums/viewtopic.php?t=4714) ---
  Community discussion of DFS disc image formats and variants.
