Skip to content

Phase 34 os path plan

Simon Dick edited this page Jun 20, 2026 · 2 revisions

Phase 34 — Frozen os extensions + AmigaOS-aware os.path

Part of the Amiga port design log.

Closes the os / os.path gap that showed up when comparing our port to OoZe1911's. The C-side os module remains extensible (via MP_REGISTER_EXTENSIBLE_MODULE), and a frozen os.py / _ospath.py merges on top so the standard surface (chdir / getcwd / listdir / mkdir / remove / rename / rmdir / stat / statvfs / getenv / putenv / unsetenv) coexists with the new additions.

import os

os.makedirs("Work:scripts/sub/dir", exist_ok=True)
for root, dirs, files in os.walk("Work:"):
    print(root, len(files))

os.chmod("DH0:foo", 0)              # 0 = no denials -- grant R, W, E, D
mask = os.getprotect("DH0:foo")     # raw fib_Protection ULONG
print(mask & os.FIBF_DELETE)        # nonzero ⇒ delete is denied

os.path.join("Work:", "scripts", "foo.py")   # 'Work:scripts/foo.py'
os.path.abspath("foo.py")                    # uses cwd, volume aware
os.path.isabs("Sys:Prefs")                   # True (':' anywhere)
os.path.normpath("Work:scripts/../bin")      # 'Work:bin'

Design and rationale

Surface

os.* additions:

Call Returns Notes
os.chmod(path, mask) None SetProtection wrapper. Suppresses AmigaDOS auto-requesters. OSError (translated AmigaDOS errno) on failure.
os.getprotect(path) int Lock + Examine + read fib_Protection. Returns the raw 32-bit mask; AND with FIBF_* to test bits.
os.makedirs(name, exist_ok=False) None Recursive mkdir, volume-aware. The volume prefix itself isn't created. EEXIST family swallowed when exist_ok=True.
os.walk(top, topdown=True) generator Yields (dirpath, dirnames, filenames) triples. Same shape as CPython; no onerror / followlinks (AmigaOS has no symlinks).
os.FIBF_READ / WRITE / EXECUTE / DELETE int RWED bits. Set bit ⇒ denied (AmigaDOS inverted convention).
os.FIBF_ARCHIVE / PURE / SCRIPT / HOLD int APSH bits. Set bit ⇒ asserted.
os.path module The frozen _ospath module (volume-aware path helpers).

os.path (== _ospath) surface:

Call Returns Notes
path.join(*parts) str No separator after : (volume terminator); / between other components. A part containing : resets the join.
path.split(p) (dir, base) Splits at the last / or :. : stays on the dirname side.
path.splitext(p) (root, ext) Last . after the last separator. Leading . (dot-files) returns ("", p) semantics on basename.
path.basename(p) / path.dirname(p) str split(p)[1] / split(p)[0].
path.isabs(p) bool True if p contains any : (AmigaDOS treats Work:, :foo, Sys:Prefs all as absolute).
path.abspath(p) str p if isabs(p); else join(getcwd(), p) then normpath.
path.normpath(p) str Collapses . / ..; clamps at the volume boundary (Work:.. stays Work:).
path.exists(p) / path.isfile(p) / path.isdir(p) bool Wrap os.stat. False on any OSError.

AmigaDOS protection-bit semantics

The four RWED bits in fib_Protection are inverted relative to Unix:

Bit Set means Clear means
FIBF_READ read denied read allowed
FIBF_WRITE write denied write allowed
FIBF_EXECUTE execute denied execute allowed
FIBF_DELETE delete denied delete allowed

So os.chmod(path, 0) grants R, W, E, and D (nothing denied), and os.chmod(path, os.FIBF_DELETE) denies only delete.

The APSH bits follow the conventional "set means yes":

Bit Set means
FIBF_ARCHIVE file has been backed up since last change
FIBF_PURE binary is reentrant (can be made resident)
FIBF_SCRIPT file is a Shell script
FIBF_HOLD keep a pure module resident on first use

This matches the AmigaShell Protect command's encoding.

Out of scope

  • Posix mode → AmigaDOS bit translation for chmod. rwx and rwed don't map cleanly; callers wanting cross-platform code should branch on sys.platform.
  • os.path.expanduser (no ~ concept), expandvars (would have to walk dos.library GetVar — separate phase if needed).
  • os.walk follow-symlinks / onerror callback. AmigaOS has no symlinks and the on-error case is rare enough to wrap in user code.
  • Full CPython os.path parity (commonpath, relpath, samefile, getmtime, ...) — easy to add later if a real call site needs them.
  • os.environ mapping interface — Phase 20's getenv / putenv / unsetenv are enough for now.

Dependencies

  • dos.library SetProtection / Lock / Examine / UnLock (V36+).

Implementation / step plan

The C-side os module (registered as MP_REGISTER_EXTENSIBLE_MODULE) merges with a frozen os.py so the existing C entries stay live and the frozen file adds the rest.

Phasing overview

Step 1: os.chmod + os.getprotect (C entries) + FIBF_* constants
                                          ↓
                              Step 2: Frozen os.py + _ospath.py
                                          ↓
                                Step 3: Docs flip + smoke tests
# Step Output On-target smoke
1 New port-local modosamiga.c registering _osamiga with chmod(path, mask) (SetProtection), getprotect(path) (Lock+Examine+fib_Protection), and the FIBF_* constants. Surfaced via the frozen os.py from _osamiga import ... in Step 2 (rather than amending extmod/modos.c, which would violate the port-separation rule). New port-local C module. From the REPL under Amiberry: os.getprotect("S:Startup-Sequence") returns a mask; os.chmod round-trips.
2 Frozen ports/amiga/modules/os.py with makedirs (recursive mkdir, AmigaOS volume-aware) and walk (recursive listdir + stat tree generator). Frozen ports/amiga/modules/_ospath.py with join / split / splitext / basename / dirname / exists / isfile / isdir / isabs / abspath / normpath. os.py does import _ospath as path so os.path Just Works. Two frozen modules. os.makedirs + os.walk against a temp tree. os.path.normpath collapses .. correctly across volume separators.
3 Docs flip, manifest verification, smoke test. docs/amiga.md Phase 34 → ✅. docs/amiga-testing.md gains a short os / os.path subsection. tests/ports/amiga/test_os_smoke.py covers the surface and the volume-separator edge cases under vamos.

Each step is small: Step 1 is ~50 LOC C, Step 2 is ~150 LOC Python, Step 3 is paperwork.


Step 1 — os.chmod + os.getprotect + FIBF_* constants

Deliverables

  • Two C functions in ports/amiga/modosamiga.c:
    static mp_obj_t mod_osamiga_chmod(mp_obj_t path_obj, mp_obj_t mask_obj);
    static mp_obj_t mod_osamiga_getprotect(mp_obj_t path_obj);
    Registered as the _osamiga module via MP_REGISTER_MODULE. The frozen os.py (Step 2) does from _osamiga import chmod, getprotect, FIBF_* so callers see them on os.
  • os.chmod(path, mask):
    • Suppress AmigaDOS auto-requesters (pr_WindowPtr = -1) around the call so a path on an unmounted volume gets a clean OSError instead of a system dialog (matches amiga.exists() / amiga.match()).
    • SetProtection(name, mask); raises OSError on failure with the errno mapping amiga_dos_errno_from.
  • os.getprotect(path):
    • Lock(path, SHARED_LOCK)Examine(lock, &fib)fib.fib_Protection. UnLock on the way out.
    • Same auto-requester suppression.
    • Returns the raw fib_Protection ULONG.
  • FIBF_* constants exposed on _osamiga and pulled into os.* via the frozen os.py star-list import:
    • os.FIBF_READ, os.FIBF_WRITE, os.FIBF_EXECUTE, os.FIBF_DELETE, os.FIBF_ARCHIVE, os.FIBF_PURE, os.FIBF_SCRIPT, os.FIBF_HOLD (the NDK ships HOLD, not HIDDEN; the plan was wrong here).

Bit semantics caveat

AmigaDOS protection flags are inverted for the four classic RWED bits — a set bit means "denied", a clear bit means "allowed." The bits exposed by FIBF_READ / WRITE / EXECUTE / DELETE follow the on-disk encoding, so chmod(path, 0) grants all four (no bits set = nothing denied). ARCHIVE, PURE, SCRIPT, HIDDEN follow the "set means yes" convention.

Document the inversion in the module docstring so callers aren't surprised; this is the same convention Protect from the AmigaShell uses.

Verification

REPL on target:

>>> import os
>>> oct(os.getprotect("S:Startup-Sequence"))   # readable, writable
'0o15'
>>> os.chmod("Ram Disk:test", 0)               # all allowed, no flags
>>> os.getprotect("Ram Disk:test")
0

Vamos: dos.library's SetProtection / Examine work end-to-end through mp: volumes, so the call paths are testable.


Step 2 — Frozen os.py + _ospath.py

Deliverables

ports/amiga/modules/os.py
# Frozen extension for the C-side `os` module. The C entries
# (chdir, getcwd, listdir, mkdir, remove, rename, rmdir, stat,
# statvfs, getenv, putenv, unsetenv, chmod, getprotect, plus the
# FIBF_* constants) are already in this module's globals when
# this file is loaded -- the extensible-module mechanism merges
# them in.

import _ospath as path  # exposes amiga.os.path


def makedirs(name, exist_ok=False):
    """mkdir -p semantics, AmigaOS volume aware."""
    # walk Volume:dir1/dir2/dir3 component by component


def walk(top, topdown=True):
    """Recursive listdir + stat tree generator."""
    # follow same shape as CPython os.walk

makedirs handles Work:scripts/sub/dir:

  • Split off Work: as the volume prefix (must exist).
  • For each subsequent component, accumulate the path with / and mkdir it, tolerating EEXIST when exist_ok=True.

walk follows CPython's os.walk shape — (dirpath, dirnames, filenames) triples, topdown ordering preserved. AmigaOS paths join correctly when the parent ends with : (no extra /) vs when it ends with anything else.

ports/amiga/modules/_ospath.py

Pure-Python helpers. Volume-aware policy: the first : in a path is the volume terminator (Sys:Prefs/Workbench); subsequent / characters separate components. A leading : is illegal in AmigaOS so we don't need to worry about that edge case.

Functions:

  • join(*parts) — concatenate with the right separator:
    • join("Work:", "scripts")"Work:scripts" (no separator after :)
    • join("Work:scripts", "foo.py")"Work:scripts/foo.py"
    • A part containing : resets the join (it's a fresh absolute path).
  • split(p)(dirname, basename). Splits at the last / or :; the separator character is retained on the dirname side.
  • splitext(p)(root, ext). Last . after the last separator.
  • basename(p) → second element of split.
  • dirname(p) → first element of split.
  • isabs(p) — has a : (volume reference) anywhere in the path, not just at index 0. AmigaOS treats Work: and :foo and Sys:Prefs/Workbench all as absolute.
  • abspath(p)p if isabs(p), else join(getcwd(), p).
  • normpath(p) — collapse . and .. components without crossing the volume boundary (Work:.. is meaningless and stays as-is).
  • exists, isfile, isdir — wrap stat().

Verification

>>> os.path.join("Work:", "scripts", "foo.py")
'Work:scripts/foo.py'
>>> os.path.normpath("Work:scripts/../bin/./tool")
'Work:bin/tool'
>>> os.path.isabs("Sys:")
True
>>> os.path.isabs("foo.py")
False
>>> os.path.split("Work:scripts/foo.py")
('Work:scripts', 'foo.py')
>>> os.path.split("Work:foo.py")
('Work:', 'foo.py')
>>> os.makedirs("Ram Disk:a/b/c", exist_ok=True)

Step 3 — Docs + tests

Deliverables

  • docs/amiga.md Phase 34 status → ✅; section gains the full surface matrix (which calls / which module / what they return).
  • docs/amiga-testing.md short os / os.path subsection with REPL examples and a note about the AmigaDOS inverted-bit semantics.
  • tests/ports/amiga/test_os_smoke.py — vamos-runnable:
    • os.path.isabs("Sys:") is True; isabs("foo.py") is False
    • os.path.join / split / normpath corner cases
    • os.makedirs round-trip against mp: tree
    • os.chmod / os.getprotect round-trip on a temp file
    • os.walk yields the expected (dirpath, dirs, files) shape

Cross-cutting concerns

  • Volume-vs-directory separator policy. : terminates the volume once per path; / separates directories after that. Pure-Python helpers in _ospath.py carry that invariant; the C entries already do (they pass the string straight through to dos.library).
  • AmigaDOS protection-bit inversion. Document loudly in _ospath.py / os.py / docs/amiga-testing.md. Set bit ⇒ denial for RWED; set bit ⇒ assertion for APSH.
  • No CPython-compat translation layer for chmod. Posix rwx → AmigaDOS rwed is ambiguous and lossy; we take the AmigaDOS mask directly. CPython interop scripts that need cross-platform chmod should use if sys.platform == 'amiga'.
  • Extensible-module attribute lookup. When the frozen os.py runs, the C-side globals are already populated, so functions defined in the frozen file shadow nothing — they extend. If a future C-side change adds e.g. a mkdir with a new signature, the frozen file's makedirs continues to call it through the merged namespace (no name change needed).

Out-of-scope items reaffirmed

  • Posix mode translation for chmod — ambiguous, see above.
  • os.path.expanduser / expandvars — AmigaOS uses dos.library GetVar and there's no ~ concept.
  • os.walk follow-symlinks / on-error callback — keep minimal shape; users wrap if needed.
  • Full CPython os.path parity (commonpath, relpath, samefile, getmtime, etc.) — add later if a real call site needs them.
  • os.environ mapping interface — Phase 20's getenv / putenv / unsetenv are enough for now.

Files

ports/amiga/modosamiga.c                — chmod + getprotect + FIBF_*
ports/amiga/modules/os.py               — frozen extension (makedirs,
                                          walk, _osamiga re-exports,
                                          import _ospath as path)
ports/amiga/modules/_ospath.py          — AmigaOS-aware path helpers
tests/ports/amiga/test_os_smoke.py      — vamos smoke + RAM: round trip
docs/amiga.md                           — Phase 34 status + surface matrix
docs/amiga-testing.md                   — os / os.path subsection

Variants: all three. ~5 KB text per variant (1 KB C + 4 KB frozen Python bytecode).

Clone this wiki locally