Metadata-Version: 2.5
Name: a2til
Version: 0.9.0
Summary: Dependency-free Apple II disk-image toolkit: ProDOS, DOS 3.3, Apple Pascal, HFS and NuFX/ShrinkIt, read and write
Project-URL: Homepage, https://github.com/emdeejay/a2til
Project-URL: Repository, https://github.com/emdeejay/a2til
Project-URL: Issues, https://github.com/emdeejay/a2til/issues
Project-URL: Changelog, https://github.com/emdeejay/a2til/blob/main/CHANGELOG.md
Author: mdj
License-Expression: MIT
License-File: LICENSE
Keywords: 2mg,apple2,disk-image,dos33,filesystem,hfs,nufx,pascal,prodos,retrocomputing,shrinkit
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Archiving
Classifier: Topic :: System :: Emulators
Classifier: Topic :: System :: Filesystems
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: hypothesis>=6; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# a2til

A dependency-free **Apple II disk-image toolkit** for Python — read *and*
write **ProDOS**, **DOS 3.3**, **Apple Pascal**, **HFS** and
**NuFX/ShrinkIt**, in `.po`, `.hdv`, `.2mg`, `.dsk`/`.do`, `.shk`, `.bxy` and
`.sdk` files.

| format | read | write | notes |
|---|---|---|---|
| **ProDOS** | ✅ | ✅ full | seedling/sapling/tree, extended (forked) files, sparse files, GS/OS mixed-case names, 2IMG wrappers, DOS 3.3 sector order (auto-detected and deinterleaved) |
| **DOS 3.3** | ✅ | ✅ full | flat catalog: no directories, no rename |
| **Apple Pascal** | ✅ | ✅ full | flat catalog, strictly contiguous files |
| **NuFX / ShrinkIt** | ✅ | ✅ full | `.shk`/`.bxy` archives as a namespace of ProDOS-attributed forked files; a `.sdk` opens as the volume inside it. Reads every ShrinkIt codec (Squeeze, LZW/1, LZW/2, LZC-12/16, Deflate, BZip2); writes LZW/2 or uncompressed |
| **HFS (standard)** | ✅ | ◐ never allocates | overlay a fork in place, set a record's metadata, resize a fork within what it already owns. No create, delete, rename or growth — see [notes/hfs-write.md](notes/hfs-write.md) |

- **Write** (ProDOS/DOS 3.3/Pascal/NuFX): create files (sparse-aware), delete,
  `mkdir`, overwrite, rename, edit metadata, grow directories, format fresh
  volumes — every mutation transactional and rolled back atomically on error.
- **Interfaces**: an `os`-style module API, an interactive FTP-style shell, and
  a one-shot CLI.
- **Translate**: `get`/`put`/`cat` translate Apple text and tokenized
  Applesoft/Integer/Business BASIC by default; `a2til mirror` poll-syncs a
  ProDOS directory with a host directory, translating both ways.
- Pure standard library. Cross-checked byte-for-byte against three independent
  implementations — [cadius](https://github.com/mach-kernel/cadius) for
  ProDOS, [NuLib2](https://www.nulib.com/) for NuFX,
  [hfsutils](https://www.mars.org/home/rob/proj/hfs/) for HFS — and against
  real disks (it recreates *Total Replay v5.2* — 32 MB, 2480 files — with a
  clean `CHECKVOLUME`).

## Install

```sh
uv tool install a2til      # or: pipx install a2til
```

## Command line

```sh
a2til                      # interactive FTP-style shell (no image)
a2til disk.2mg             # shell, with disk.2mg mounted
a2til info  disk.2mg
a2til ls    disk.2mg /SUBDIR
a2til cat   disk.2mg /HELLO
a2til check disk.2mg            # integrity check (exit 1 if problems)
a2til repair disk.2mg           # fix the one field older a2til builds got wrong
a2til convert notes.txt notes.raw   # host <-> Apple text, no image involved

# heredoc / pipe friendly:
printf 'cd GAMES\nget LODE.RUNNER\nbye\n' | a2til disk.2mg
```

The shell and the `info`/`ls`/`cat`/`check` one-shots work on ProDOS, DOS 3.3,
and Apple Pascal images alike (auto-detected). DOS 3.3 and Pascal are flat
catalogs — no subdirectories — so `cd`/`mkdir`/`rmdir` only work on ProDOS,
`mv` is ProDOS/Pascal only (DOS 3.3 has no rename), `-r`/`-a` (resource
forks/AppleSingle) are ProDOS-only, and `check` is ProDOS/DOS 3.3 only
(Pascal has no checker) — the prompt shows the format for the flat engines
(`a2til:dos33>`), and any unsupported operation prints a one-line error
instead of a traceback. `a2til mirror` (below) stays ProDOS-only.

On DOS 3.3, `get`/`get -b`/`cat` return the file's on-disk bytes, headers and
sector padding included (`read_file`/`read_raw` are the same call); the
BASIC/text converters strip what they need. `put` of a `.bas`/`.txt` file
stores a tokenized `A`/high-ASCII `T` file — the engine re-adds the on-disk
length header itself, so the converter's own header is stripped first. DOS
3.3 has no truly raw file type (every type carries a header), so `put -b`,
and any unmatched extension, store a type-`B` file at load address 0. Pascal
has no converters at all yet (its TEXT format is future work): `get`/`put`
always move raw bytes.

**`get`/`put`/`cat`/`mget`/`mput` translate known text and BASIC files by
default** — like FTP's ascii mode: ProDOS/DOS 3.3 `TXT` bytes (CR line
endings, DOS 3.3's high-ASCII) become plain host text on the way out and back
again on the way in; tokenized Applesoft (`BAS`), Integer (`INT`), and Apple
/// Business BASIC (`BA3`) programs become readable `.bas`/`.int`/`.ba3`
listings and — for Applesoft and Business BASIC — re-tokenize on the way
back in. Integer BASIC is *read-only* translation: its tokenizer was
syntax-directed (the same `=` or `,` crunches to different bytes by
statement context), so `put` of a `.int` listing is refused rather than
risk storing a program that misbehaves; use `put -b`, or a syntax-aware
tool like [a2kit](https://github.com/dfgordon/a2kit), to write one. `get`
appends the matching extension to the local name. **Pass `-b`/`--raw` for
byte-exact transfers** — the old, untranslated behavior. Files with no
registered converter always move raw, silently, whether or not `-b` is
given.

In the shell: `ls`, `cd`, `pwd`, `cat [-b]`, `get [-b] [-r|-a]`, `mget [-b] [-r]`,
`put [-b]`, `mput [-b]`, `rm`, `mkdir`, `rmdir`, `mv`, `info`, `check`, `open`,
plus `lcd`/`lls`/`lpwd` and `!` for the local shell — with tab completion of
in-image and local paths.

## ShrinkIt archives

`.shk` and `.bxy` archives open like any other image, because a NuFX archive
*is* a namespace of ProDOS-attributed forked files — the type and aux bytes
are real ProDOS ones, so `get HELLO` detokenises Applesoft out of an archive
exactly as it does out of a disk:

```sh
a2til ls    game.shk
a2til cat   game.shk /GAME/README
printf 'cd SRC\nget HELLO.BAS\nbye\n' | a2til game.shk
```

A `.sdk` is a disk image *inside* an archive; a2til unwraps it and shows you
the volume, so `a2til disk.sdk` behaves like `a2til disk.po` — including
writes, which are recompressed back into the archive on close.

```python
from a2til import nufx

with nufx.NufxArchive('game.shk') as arc:
    for entry in arc.scandir('/'):
        print(entry.name, entry.size, entry.rsrc_size, entry.type)
    data = arc.read_file('/SRC/HELLO', fork='data')
    arc.write_file('/SRC/NOTES', b'added\r', file_type=0x04)
    arc.remove('/SRC/OLD')
```

An archive is *rebuilt* rather than patched in place — threads are compressed
and variable-length, so there is no unit to write through — and the rebuild is
atomic: a record a2til did not touch is re-emitted from its own bytes, so
adding one file leaves every other byte-identical. `check` and free space are
absent on purpose: an archive has no blocks, and a fabricated block count
would be a lie with a number on it.

## HFS

HFS (standard) volumes — System-6/7-era Mac disks, reachable from the Apple II
side through GS/OS's HFS FST — read completely: both forks of every file,
extents-overflow continuations, MacRoman names, allocation blocks of any size.

```python
from a2til import hfs

vol = hfs.HfsVolume('disk7.2mg', readonly=True)
for entry in vol.scandir('/System Folder'):
    print(entry.type, entry.creator, entry.size, entry.rsrc_size, entry.name)
data = vol.read_file('/System Folder/p8')
rsrc = vol.read_file('/System Folder/p8', fork='rsrc')
```

Paths come in two spellings, because `/` is an ordinary character in an HFS
name (Apple's own disks carry `Apple //e Boot Blocks`). `/`-separated is the
convenient one; HFS's real separator is `:`, and a path containing one is read
as HFS syntax — `Mac HD:System Folder:p8` names the volume first,
`:System Folder:p8` starts at the root.

**Writing is narrow, and narrow in one specific way: nothing allocates.**

```python
with hfs.HfsVolume('disk7.2mg') as vol:
    entry = vol.entry_for('/System Folder/p8')
    entry.overlay(rebuild(entry.read()))        # same length, or refused
    entry.set_file_info(type='PS16', creator='pdos')
    entry.resize(len(shorter)); entry.overlay(shorter)
```

`overlay` rewrites a fork's content into the blocks it already occupies;
`set_file_info` writes one catalog record's metadata; `resize` sets a fork's
logical length within what its extents already cover. The allocation bitmap
and every block placement survive all three, which is what makes a byte-exact
disk reconstruction possible — overlay every file with the content just read
from it and the image is unchanged, byte for byte.

There is no create, delete, rename, or unbounded growth: those need bitmap
maintenance and B\*-tree mutation, whose failure mode is a subtly wrong node
split that reads back fine and loses files three writes later.
[notes/hfs-write.md](notes/hfs-write.md) measures what each tier would cost
and why the refusal is the better product.

## Mirror

`a2til mirror IMAGE:/VOL/DIR LOCALDIR` polls a ProDOS directory and a host
directory and keeps them in sync (ProDOS only — DOS 3.3/Pascal mirroring is
a separate, later effort) — edit files with any editor while an
emulator has the disk mounted, no NuFX/ShrinkIt shuffle required. It's a
three-way sync against a small JSON sidecar (`LOCALDIR/.a2til-mirror.json`)
recording the last agreed state of each pair: changed on one side pushes or
pulls, changed on *both* sides since the last sync is a conflict (skipped and
logged, or resolved with `--prefer image`/`--prefer host`), and a delete
propagates only when the other side didn't also change. Every image mutation
in a pass is one transaction — a failure rolls the image back and leaves the
sidecar untouched, so a pass is all-or-nothing. Before each pass the image's
mtime must have been stable for a full `--interval` (default 2s), so mirror
never opens an image an emulator might still be writing.

```sh
a2til mirror games.po:/GAMES/SRC ./src            # poll every 2s
a2til mirror games.po:/GAMES/SRC ./src --once      # one pass, then exit
a2til mirror games.po:/GAMES/SRC ./src --raw       # byte-exact, no translation
a2til mirror games.po:/GAMES/SRC ./src --prefer host   # host wins conflicts
```

Translation defaults on, same dispatch as `get`/`put`: edit `hello.bas` as
plain text on the host —

```sh
$ echo '10 PRINT "HI THERE"' > ./src/hello.bas
$ a2til mirror games.po:/GAMES/SRC ./src --once
push hello.bas -> HELLO (11 bytes)
$ a2til cat games.po /GAMES/SRC/HELLO
10  PRINT "HI THERE"
```

— and the next time the emulator lists `HELLO`, it's a real tokenized
Applesoft program. Unconverted types raw-copy with a cadius-style
`NAME#TTAAAA` host suffix (parsed back on the way in); Integer BASIC's `put`
is refused for the same syntax-directed-tokenizer reason `put`/`convert` are
(see above) — that shows up as a `refused: ...` log line for just that file,
not a failed pass.

## Library

```python
from a2til import prodos

prodos.mount('disk.2mg')                  # -> 'TESTVOL'
for entry in prodos.scandir('/TESTVOL'):
    print(entry.name, entry.size, entry.is_dir())

data = prodos.read('/TESTVOL/HELLO')      # data fork
rsrc = prodos.read('/TESTVOL/FORKED', fork='rsrc')
prodos.write('/MYVOL/F', data, resource=rsrc)   # create an extended (forked) file
prodos.rename('/MYVOL/F', '/MYVOL/DOCS/F')      # rename or move (data never moves)
prodos.set_file_info('/MYVOL/DOCS/F', access=0x21)   # lock (0xE3 unlocks)

# writing needs a writable backing (file opened r+b, or a bytearray)
prodos.create('new.po', 'MYVOL', blocks=1600)
# dir_blocks sizes the volume directory: 13 entries a block, less the
# header, and ProDOS never grows it later. The default 4 gives 51.
prodos.create('wide.po', 'WIDE', blocks=1600, dir_blocks=16)   # 207 entries
prodos.mount('new.po')
prodos.mkdir('/MYVOL/DOCS')
prodos.write('/MYVOL/DOCS/README', b'hello\n', file_type=0x04)
prodos.unmount('MYVOL')                   # flushes to disk
```

> **Temporary (until 2026-11-01):** older a2til builds wrote one subdirectory
> header field — `parent_entry` — one entry early, which real ProDOS follows
> off the volume and hangs on `DESTROY` two levels deep. Opening such an image
> *reports* it (`volume.repairs_needed`, and a line on stderr from the CLI)
> and changes nothing; `a2til repair IMAGE`, `Volume(path, repair=True)` or
> `volume.repair()` fixes it. See `notes/kludges.md`.

Both `prodos.scandir` and `Volume.scandir` yield the same `DirEntry`, so code
moves between the two APIs unchanged. Predicates are methods (`is_dir()`,
`is_file()`, `is_extended()`) and data is plain attributes (`name`, `path`,
`size`, `rsrc_size`, `type`, `aux_type`, `created`, `modified`); `entry.stat()`
hands back the raw on-disk directory entry for anyone who wants its fields.
Every engine's entries answer `is_dir()`/`is_file()` the same way.

**Writing to a damaged volume is refused.** The shell's write commands and
`a2til mirror` run `check()` when they open an image and stop if it fails,
because a2til trusts a volume's metadata the way every filesystem trusts its
own — the allocator believes the bitmap, so a bitmap that calls a live file's
block free will hand that block to the next write. Reading is never gated:
the files are still there to rescue. `force on` in the shell, or `--force` for
mirror, says you meant it.

**What to catch.** `ValueError` means the argument was wrong — a name the
format cannot hold, a size that does not fit, an unknown fork. `OSError` means
the volume could not do it, with the usual subclasses where they apply
(`FileNotFoundError`, `FileExistsError`, `IsADirectoryError`,
`NotADirectoryError`, `PermissionError` on a read-only image) and
`errno.ENOSPC` when the answer is "no room" — a full volume and a full
directory alike, on every engine.

`Volume` is the engine and can be used directly, constructed from a path or a
`bytes`/`bytearray`/`memoryview` buffer. Pass `readonly=True` (to `Volume` or
`mount`) to open without write intent: the image is mapped read-only — so
files on read-only media open fine — and any mutation raises
`PermissionError` up front.

### Sessions

The `prodos.mount`/`open`/`read` module functions are convenience wrappers over
a single process-global `Session` — handy for scripts, the shell, and the
examples above. `Session` itself is the composable API: it is a format-neutral
mount table and fd registry, so construct your own when you want isolation (no
shared global state), multiple independent mount tables, or to mix engines.

```python
from a2til import Session, prodos

sess = Session()
sess.mount('MYVOL', prodos.Volume('disk.2mg'))
vol = sess.get('MYVOL')
for entry in vol.scandir('/'):
    print(entry.name)
sess.unmount('MYVOL')          # severs its fds and flushes to disk
```

## Stability

The version number is a promise about the **API**: from 1.0, a call site that
works keeps working until a major release. It is not a claim that every engine
has seen equal use, which is a different thing and the thing you probably want
to know:

| engine | maturity | what that rests on |
|---|---|---|
| **ProDOS** | stable | exercised many times a day by the author's other projects; oracled against cadius; recreates a 32 MB real disk (*Total Replay v5.2*, 2480 files) byte-for-byte |
| **DOS 3.3** | read: used · write: unexercised | reading is how old files get pulled off period disks and gets real traffic; the writer is fixture- and shell-tested but has no real-world mileage |
| **Apple Pascal** | provisional | full read/write, fixture- and shell-tested, essentially no real-world traffic |
| **NuFX/ShrinkIt** | provisional | oracled against NuLib2 across every codec, against a real 111-record archive that must come back byte-identical, and — for a 32 MB disk image — producing *the same compressed bytes* NufxLib produces. Provisional because it is unused, not because it is unchecked |
| **HFS** | read: proven · write: provisional | reading is oracled against hfsutils and validated against Apple's System 6.0.1 disks in their redistributed 2IMG form; the write surface is deliberately tiny and *will* grow (see [notes/hfs-write.md](notes/hfs-write.md)), so it will change |

"Provisional" means what it says: those APIs may change in a minor release,
and the changelog will say so. ProDOS will not.

One asymmetry worth stating rather than leaving to be discovered:
`open_image`/`detect_format` hand back ProDOS, DOS 3.3, Pascal and NuFX
volumes, but **not HFS** — the factory promises a volume you can name a path
in and write to, and HFS offers three verbs that never allocate. Reach for
`hfs.HfsVolume` directly.

## Develop

```sh
uv pip install -e '.[dev]'   # or: pip install -e '.[dev]'
pytest                       # ~1300 unit, integration and differential tests
```

The interesting half of the suite is *differential*: a2til is checked against
independent implementations rather than against itself, because a fixture we
generate cannot disagree with the reader we wrote it for.

```sh
brew install cadius hfsutils     # ProDOS and HFS oracles
cargo install a2kit              # Applesoft/Integer tokeniser oracle
test/oracle/build-nulib2.sh      # NuFX oracle (no formula; source build)
```

Each module skips cleanly when its tool is absent, so `pytest` stays
dependency-free — see [notes/oracles.md](notes/oracles.md) for what each one
proves and how it is driven. Set `A2TIL_REQUIRE_ORACLES=1` to turn those skips
into failures, which is what CI should do: an oracle that quietly stops
installing otherwise looks exactly like an oracle that passes.

`test/test_fuzz.py` is Hypothesis-based robustness testing over malformed
images. `notes/` carries the design record — why the engines are split, why
archives rebuild rather than undo-log, what HFS writes would cost, and what
is deliberately not built.

## License

MIT
