Metadata-Version: 2.5
Name: golded-ftn-hudson
Version: 1.3.0
Summary: Strict reader and offline writer for classic Hudson bases
Project-URL: Repository, https://github.com/golded-dev/golded-ftn-hudson-python
Project-URL: Issues, https://github.com/golded-dev/golded-ftn-hudson-python/issues
Project-URL: Changelog, https://github.com/golded-dev/golded-ftn-hudson-python/blob/main/CHANGELOG.md
Author: GoldED.dev contributors
License-Expression: MIT
License-File: LICENSE
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: golded-ftn<2,>=1.4.0
Description-Content-Type: text/markdown

# golded-ftn-hudson

Repository: [`golded-ftn-hudson-python`](https://github.com/golded-dev/golded-ftn-hudson-python).
The distribution remains `golded-ftn-hudson`; imports use `golded_ftn_hudson`.
The source is public on GitHub. [Version 1.3.0 is available on PyPI](https://pypi.org/project/golded-ftn-hudson/1.3.0/).

Install with Python 3.12 or newer:

```sh
python -m pip install golded-ftn-hudson==1.3.0
```

Read and edit classic Hudson bases through the `golded-ftn` models. Python 3.12+.

For development, clone core and the format package:

```sh
git clone https://github.com/golded-dev/golded-ftn-python.git
git clone https://github.com/golded-dev/golded-ftn-hudson-python.git
cd golded-ftn-hudson-python
uv sync --locked
```

```python
from pathlib import Path
from tempfile import TemporaryDirectory

from golded_ftn import MessageBaseReader, ReaderOptions
from golded_ftn_hudson import HudsonReader

# An empty base. Real callers pass the directory containing the three files.
with TemporaryDirectory() as directory:
    base = Path(directory)
    for filename in ("MSGIDX.BBS", "MSGHDR.BBS", "MSGTXT.BBS"):
        (base / filename).write_bytes(b"")
    reader: MessageBaseReader = HudsonReader()
    messages = list(reader.read(base, ReaderOptions(fallback_charset="CP850")))
    assert messages == []
```

The directory must contain one regular file for each required name. Names are
matched without regard to case; symlinks and ambiguous variants are rejected.
The standalone reader ignores `MSGINFO.BBS`, `MSGTOIDX.BBS` and `LASTREAD.BBS`.
Writer sessions validate and maintain the first two, and preserve lastread data.
This reader supports
classic `.BBS` Hudson only. GoldBase `.DAT` files use different records.

The 3-byte index determines the corresponding 187-byte header slot. Active
message numbers are unsigned 1–65534; only `0xffff` marks a deleted index slot.
Headers marked deleted are also skipped. Active index/header numbers and boards
must agree; active message numbers must be globally unique. Boards are 1–200.
All boards are read, in ascending message-number order. Complete unindexed
header records and unused text blocks are ignored.

Text uses 256-byte blocks: one Pascal length byte followed by 0–255 payload
bytes. The reader joins the declared payloads before decoding, even when UTF-8
characters or control lines span blocks. Header strings also use Pascal lengths.
Bytes outside each declared payload are padding. Short and empty text blocks,
zero-block bodies and messages without a final NUL are accepted. A zero-block
body ignores its unused start-record value. Core body
normalization removes trailing NULs and normalizes line endings.

The reader validates record alignment, header availability, Pascal field bounds
and active text spans. By default it loads the index, seeks headers and text
spans, and yields messages as they are parsed. Pass `ReaderOptions(eager=True)`
to parse every active message before yielding; in eager mode a malformed later
message cannot leave a partial result. Filesystem errors remain filesystem
errors. Damaged data and strict decoding failures raise `ParserException` with
the actual filename, physical byte offset and chained cause. Complete unused
records are not checked for content validity; file alignment is checked
throughout.

Read a stable directory with no concurrent writes. Files are read separately,
without locking or a transactional snapshot. Stop the writer or use a consistent
copy before reading.

Charset detection and decoding use core helpers with CP850 fallback. Repeated
charset declarations must agree; unknown names use the configured fallback and
must agree by name. Repeated MSGID and REPLY values must agree. The body retains
control lines. Routing and control entries preserve their source order within
the model's separate sequences. Colon-form and traditional space-form INTL,
FMPT and TOPT supply address metadata; conflicting values fail. Nonzero header
values are preserved, including node 0 when a net establishes it. Incomplete
addresses become `None`. No domain is invented from IDs or Origin lines.
Mojibake repair belongs to callers.

Dates use `MM-DD-YY HH:MM`, with GoldED's pivot: 00–79 means 2000–2079;
80–99 means 1980–1999. Values are timezone-naive and independent of machine
settings. Missing or invalid dates become `None`; invalid Pascal lengths still
fail. Timezone controls remain control metadata.

`attributes_raw` combines the message attribute byte with the network attribute
byte shifted left eight bits. Reply-to and first-reply numbers are retained;
zero becomes `None`. The read-count word is not a next-reply link, so
`reply_next_msgno` is `None`. MSGID supplies `external_id`; otherwise core produces
a synthetic ID over decoded, normalized fields.

Each board becomes `area_code="BOARD<n>"` and `area_meta_key="hudson:<n>"`.
These are synthetic identifiers. `area_name` and `area_sort_order` remain `None`
because the base does not store configured area names or their display order.
Provenance identifies `hudson`, the actual `MSGHDR.BBS` path, the message number
as text and the header's physical byte offset.

There is no board filter in the standalone reader, area discovery or database adapter.

## Development

```sh
uv sync --locked --python 3.14
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run python -m mypy.stubtest golded_ftn_hudson
uv build
uv run twine check dist/*
uv run python scripts/verify_distribution.py
```

uv uses sibling `../golded-ftn-python` for development. Distribution metadata contains
only `golded-ftn>=1.4.0,<2`. The sdist hook strips the local uv source mapping;
the development lock is excluded. See [contributing](CONTRIBUTING.md) and
[release checks](docs/release.md).

## Format references

The local GoldED source is the primary implementation reference:
`golded-open-source/goldlib/gmb3/gmohuds.h` defines packed records and attributes;
`gmohuds4.cpp` writes Pascal text blocks; `gmohuds3.cpp` establishes address,
reply and date behavior. GoldBase layouts are in `goldlib/gmb4/gmbgold.h`.
[GoldED+ source](https://sourceforge.net/projects/golded-plus/) provides the
upstream project context.

Sibling `laravel-ftn-hudson` informed model mapping only. Its 128-byte raw-text
fixtures do not represent Pascal text blocks and are not copied here. Tests use
independent synthetic binary fixtures. No private message archives are included.

The package code is MIT licensed.

## Archive mode

Strict reading remains the default. Archive mode requires a report callback:

```python
from golded_ftn import ReaderIssue, ReaderOptions

issues: list[ReaderIssue] = []
options = ReaderOptions(archive_mode=True, on_issue=issues.append)
# Pass options to HudsonReader().read(source, options).
```

Issues carry `recovered`, `skipped` or `stopped`, the actual filename, record
identity and physical offset. Their detail contains no message contents. A stop
means the traversal is incomplete; a validated prefix may still be returned.
Multiple issues can describe one record, including recovery followed by a skip.
Filesystem errors and callback exceptions propagate. Files must remain stable.

Failed active records are skipped using the next fixed index/header slot.
Duplicate message numbers, missing indexed headers and file alignment failures
stop traversal. Charset, ID and address conflicts are skipped.

If declared ASCII cannot decode a payload, the configured fallback is tried
strictly and reported. The original charset control stays unchanged. Other
decoding failures are skipped; there is no lossy decoding or mojibake repair.


## Offline writer

`HudsonWriter.create(path)` creates the complete base, not one board. It rejects
any existing base filename, including case variants. Pass
`HudsonWriterOptions(extension_case="lower")` to create `msginfo.bbs` and
friends instead. Open and read already use the casing that is on disk. Initial
files are `MSGINFO.BBS` (406 zero bytes), `LASTREAD.BBS` (400 zero bytes), empty
`MSGHDR.BBS`, `MSGIDX.BBS`, `MSGTOIDX.BBS`, `MSGTXT.BBS`, `NETMAIL.BBS` and
`ECHOMAIL.BBS`. Existing lastread data is never changed by editing operations.

```python
from tempfile import TemporaryDirectory
from golded_ftn import MessagePatch, OutgoingMessage
from golded_ftn_hudson import HudsonWriter

with TemporaryDirectory() as directory:
    writer = HudsonWriter()
    writer.create(directory)
    with writer.open(directory, board=7) as session:
        added = session.append(
            OutgoingMessage(
                from_name="Odinn", to_name="Reader", subject="Hello", body_text="Hello!"
            )
        )
        current = session.read(added.identity.msgno)
        updated = session.update(
            current.identity,
            MessagePatch(body_text="A longer message"),
            current.revision,
        )
        session.delete(updated.identity, updated.revision)
```

The board is explicit and must be 1–200. Identity contains the format, resolved
base path, board and global Hudson message number. Number allocation includes
deleted headers and preserves the high-number watermark. Deleted numbers are
not reused. Replies remain untouched in other records. The read-count word is
preserved; next-reply links and reply lists cannot be represented and are rejected.

A session locks byte 407 of `MSGINFO.BBS` for each operation, re-reads the files,
validates every active record and the information counts, then releases the lock
after flushing. The lock position comes from the packed 406-byte `HudsInfo`
structure plus one. The shared core lock manager retains the lock-file descriptor
and serializes sessions in the same process. Callers must not directly open or
close base files while a session operation runs.

Revision tokens cover identity, physical header/index/text locations and SHA-256
of raw header, index, recipient-index and allocated text-block bytes. Update and
delete compare the token under the lock. A missing or changed target raises
`ConflictError`; an unrelated append does not invalidate the target token.

Updates begin with the raw header and text. Omitted fields keep their values.
Explicit `None` clears representable optional fields, including dates, addresses,
external MSGID and reply links. Required text/name fields and attributes cannot
be cleared. Unknown controls, header padding, read count, cost, attribute bits
and dates remain unless explicitly changed. A body-text patch preserves existing
control and routing lines. `control_lines` replaces general controls while
omitted `external_id`, addresses and routing keep MSGID, FMPT/TOPT/INTL and PATH.
Use the corresponding structured patch fields to replace or clear those values;
conflicting explicit controls are rejected. Pure attribute updates preserve all allocated text bytes. Content changes
append fresh 256-byte Pascal text blocks and change the existing header slot.
Old blocks remain allocated; there is no packing or repair.

`HudsonWriter.recovery_session(path, *, recovery_root, options=None, scan_path=..., board=1)`
holds the same `MSGINFO.BBS` byte-407 lock across prepare/apply/settle for the
whole physical base. Board is only a write target; it is not the lock or worker
identity. Recovery covers the common Hudson files and scan queues in `scan_dir`.
`LASTREAD.BBS` and `USERS.BBS` are never opened. Years outside 1980–2079 raise
`HudsonAdapterError` without coercing the date.

The default encoding is CP850. Serialization uses strict encoding, rejects
oversized Pascal fields, out-of-range integers and contradictory charset controls,
and does not generate MSGID or routing, or perform duplicate checks. Dates are naive and
must lie within 1980–2079. Domains cannot be stored; point addresses use explicit
FMPT/TOPT controls. `MSGTOIDX.BBS` contains the recipient, or the source's
`* Received *`/`* Deleted *` markers. Scan indices contain physical header slots,
not message numbers. Set net-transmit and echo-transmit bits enqueue the slot. Existing entries
remain until explicit deletion, matching GoldED's write path.

GoldED stores scan indices at its configured system path. Pass
`writer.open(base, board=7, scan_path=golded_system_path)` when that differs from
the base directory. The default is the base directory. Existing scan files are
validated. Missing scan files are created only when an operation adds an entry.
Empty scan files are retained after removal; GoldED treats them as empty indices.
One scan directory must belong to this base; sharing it among independent bases
is outside this lock's scope.

Each completed operation is a separate commit. Before any mutation the writer
snapshots every affected file, restores bytes and sizes in place on failure and
keeps previous completed operations. A failed rollback raises `RollbackError`
with base and operation details and makes the session unusable. This protects
handled I/O failures, not process termination or power loss. Ordinary open() has no crash
journal; use recovery_session() with a durable caller journal for staged recovery.

## GoldED compatibility profile

Candidate interoperability is bounded to GoldED source commit `d7449053bd674df54c4a442c92dce8c06ad7b586`,
version 3.1.0, native macOS arm64/GCC 16 and Ubuntu 24.04 aarch64/GCC 13.
The suite release manifest pins the resulting binaries and records actual checks.
Other GoldED versions, Windows and network filesystems are not qualified.

Writers default to offline operation. Explicit `concurrent=True` opts into the
tested read/write/refresh/lastread/stale-save profile, not a general multiwriter
guarantee. Standalone readers still need a stable base; a session read uses its
format lock. MSG/Opus and Goldbase have no shared GoldED writer lock: stale-save
detection cannot close a race between comparison and a foreign write. Keep
same-message edits serialized. Goldbase is outside the Python C1 profile.

Use format-specific WriterOptions extension_case="lower" for newly created
Unix bases used by GoldED; preserve existing casing and reject ambiguous twins.
Hudson scan queues belong in the configured system directory. Recovery sessions
have a durable journal protocol; ordinary open() sessions only roll back handled
I/O failures. UNKNOWN outcomes retain artifacts for operator reconciliation.

Source evidence and test coverage are recorded in [writer notes](docs/writer.md).

## Candidate 1.3.0

Add recoverable sessions and existing-case resolution. Keep board-specific access and configured scan directory; translate format-owned attributes at the packet boundary.

Linux and macOS, Python 3.12–3.14, are the release targets. Windows is outside
this candidate's test and compatibility promises. See [release preparation](docs/release.md).
