Skip to content
C.W.K.
Lesson 01 of 06 · published

The Root Class

~13 min · oop, polymorphism, root-class, fail-closed, api-design

Level 0Loose File
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Three Kinds of Data, One Server

Delta serves very different things. On every Mac it serves the account's home folder, a plain tree of files. On the workbench it serves an Eagle image library, where the address a caller asks for is an item id rather than a path, and the answer is whichever image inside that item is the original or the thumbnail. On the service host it serves the soul vault's own store, which has no folder a caller may walk at all: notes come out of a database-backed store, as committed bytes, and a write is something the store must carry out itself.

The obvious way to build that is a server full of branches: if this is the home share, open the path; if it is Eagle, parse the item id; if it is the vault, ask the store. Every new kind of data would edit the server, and every edit would risk the kinds already there. Delta does the opposite. Its server speaks to exactly one class, DataShare, and never asks which child it is holding. The module says it in its own words: a child overrides what its data needs; the server never special-cases a child.

What Every Share Owns

The root class holds the parts every share has, whatever it serves:

  • A name and an owner. name is checked against a short alphabet (letters, digits, _, -, .); app is the repository that declared it. A share Delta builds itself has the bare name (home); an app's share is <app>/<name>, such as cwkLoomis/eagle. That pair is the share's id, and it is the first part of every address a caller sends.
  • Who may. readers and writers are policies over Macs, and apps is the list of app names allowed to ask. Lesson 5 takes them apart.
  • Whether reads are cached on the receiving Mac (cache), a human description, free options a child reads from its own declaration, and repo_root, the folder of the app that shipped the child.

The Vocabulary

Then comes the vocabulary the server speaks. Each method has a default in the root, and the defaults are the point:

MethodWhat it answersRoot default
stat(rel)one path's metadataNotFound
file(rel, params)a regular file to serve, with its real path setNotFound
listdir(rel, recursive)the entries under a pathNotFound
validator(op, params)a cheap ETag for an api answerNone (no shortcut)
api(op, params)a read-only operation that returns JSONNotFound
derive(entry, kind, params)a recipe for a derived fileNone (not derivable)
write_path, mkdir, symlink, remove, keepfile writesForbidden
call(op, params, body, …)a write the share's owner carries out itselfForbidden
writable()whether any write is possibleFalse

Read the right-hand column again. A child that overrides nothing serves nothing and accepts nothing. Forgetting a method never opens a door; it closes one. That is what fail closed looks like when it is written into a type rather than remembered by a programmer.

Two Ways to Say No to a Write

The root even words its refusal by what the child is. A share that is not writable answers <id> is read-only. A share that IS writable but whose only write is the owner's call (the soul vault store is exactly that) answers <id> takes no file writes: its owner writes through a call. Both are Forbidden, and both travel to the caller as a named cause rather than a bare 403, so the person reading the error learns which door exists instead of guessing.

Entry: the One Shape Everything Returns

Every read method returns an Entry: a share-relative path, a kind (file, dir, link, other for anything else such as a socket or a FIFO, or unreadable for a folder that would not open), size, modification time in nanoseconds, mode, inode, device, a link's stored target, and an optional display name. Its etag is built from three of those numbers, size, mtime and inode, written in hexadecimal: "<size>-<mtime_ns>-<ino>". Change the file and at least one of them moves. The entry also carries path, the real file on disk, but only on the serving side: document(), the form that goes over the wire, leaves it out. A caller learns what a file is, never where it lives.

Why the Server Stays Small

When Loomis needed Eagle items served, nothing in Delta's server changed. Loomis shipped a child, EagleLibraryShare, that overrides file, stat, validator and api; the gateway's fs verb called share.file(rel, params) exactly as it does for the home folder. When Soul Vault needed its store served, it shipped two more children. The module docstring names the cost honestly: an app pays its own polymorphic tax. The tax is a class in the app's own repository; the payoff is that Delta's server is the same code for every kind of data the family will ever add.

This is inheritance and polymorphism doing what they are for: the root decides what is safe by default, each child decides only what its data needs, and the code that dispatches never learns the difference.

Code

Delta's root class: the vocabulary and its refusing defaults (trimmed excerpt)·python
# delta/shares.py — the root class (trimmed excerpt)

class DataShare:
    """The root class. A child overrides what its data needs; the server never
    special-cases a child."""

    kind = "share"

    def __init__(self, name, *, app=config.MEMBER, readers=None, writers=None, apps=None,
                 cache=True, description="", options=None, repo_root=None):
        if not name or set(name) - _SHARE_NAME_CHARS:
            raise ValueError(f"bad share name {name!r}")
        self.name = name
        self.app = app
        self.readers = Policy(readers)
        self.writers = Policy(writers)
        self.apps = list(apps) if apps else ["*"]
        ...

    @property
    def id(self):
        return self.name if self.app == config.MEMBER else f"{self.app}/{self.name}"

    def writable(self):
        return False

    # -- the vocabulary a child may override --
    def stat(self, rel):
        raise NotFound(f"{self.id} has no path {rel!r}")

    def file(self, rel, params=None):
        raise NotFound(f"{self.id} serves no file {rel!r}")

    def listdir(self, rel, recursive=False):
        raise NotFound(f"{self.id} lists nothing at {rel!r}")

    def validator(self, op, params):
        return None

    def api(self, op, params):
        raise NotFound(f"{self.id} has no operation {op!r}")

    def derive(self, entry, kind, params):
        return None

    def _no_file_writes(self):
        if self.writable():  # a child whose only write is the call
            return Forbidden(f"{self.id} takes no file writes: its owner writes through a call")
        return Forbidden(f"{self.id} is read-only")

    def write_path(self, rel):
        raise self._no_file_writes()

    def call(self, op, params, body, *, caller, app, request_id):
        raise Forbidden(f"{self.id} takes no write call")
Runnable: a refusing root, two children, and a dispatcher that never asks which one it holds·python
# A root class whose defaults refuse, two children, and a server that never asks which child it holds.
import os
import tempfile
from dataclasses import dataclass


class Refused(Exception):
    pass


class Missing(Exception):
    pass


@dataclass
class Entry:
    rel: str
    size: int
    mtime_ns: int
    ino: int

    @property
    def etag(self):
        return f'"{self.size:x}-{self.mtime_ns:x}-{self.ino:x}"'


class Share:
    """The root: every read answers 'nothing here', every write is refused."""

    def __init__(self, name):
        self.name = name

    def file(self, rel):
        raise Missing(f"{self.name} serves no file {rel!r}")

    def api(self, op, params):
        raise Missing(f"{self.name} has no operation {op!r}")

    def writable(self):
        return False

    def write(self, rel, data):
        raise Refused(f"{self.name} is read-only")


class TreeShare(Share):
    """A plain folder: files only, and never one outside the root."""

    def __init__(self, name, root):
        super().__init__(name)
        self.root = os.path.realpath(root)

    def file(self, rel):
        if ".." in rel.split("/"):
            raise Refused("'..' is not a path component this share follows")
        path = os.path.realpath(os.path.join(self.root, rel))
        if not path.startswith(self.root + "/") or not os.path.isfile(path):
            raise Missing(f"{self.name} has no file {rel!r}")
        st = os.stat(path)
        return Entry(rel, st.st_size, st.st_mtime_ns, st.st_ino)


class CounterShare(Share):
    """A share with no files at all: one read API, answered from memory."""

    def __init__(self, name):
        super().__init__(name)
        self.hits = 0

    def api(self, op, params):
        if op != "hits":
            return super().api(op, params)
        self.hits += 1
        return {"hits": self.hits}


def serve(share, verb, arg):
    """The server: one dispatch, no isinstance, no special case per child."""
    try:
        if verb == "file":
            entry = share.file(arg)
            return 200, {"rel": entry.rel, "etag": entry.etag}
        if verb == "api":
            return 200, share.api(arg, {})
        if verb == "write":
            share.write(arg, b"")
        return 400, {"error": "bad_request"}
    except Missing as exc:
        return 404, {"error": "not_found", "detail": str(exc)}
    except Refused as exc:
        return 403, {"error": "forbidden", "detail": str(exc)}


if __name__ == "__main__":
    with tempfile.TemporaryDirectory() as tmp:
        with open(os.path.join(tmp, "notes.txt"), "w") as f:
            f.write("hello")
        tree, counter = TreeShare("tree", tmp), CounterShare("counter")
        print(serve(tree, "file", "notes.txt"))       # 200 with an etag
        print(serve(tree, "file", "../etc/passwd"))   # 403, refused by name
        print(serve(counter, "api", "hits"))          # 200 {'hits': 1}
        print(serve(counter, "file", "anything"))     # 404 from the root default
        print(serve(tree, "write", "notes.txt"))      # 403, read-only by default

External links

Exercise

Run the runnable block, then add a third child to it: a ClockShare that serves no files and answers one API operation, now, with the current time. Do not touch serve(). Then delete your api override and run it again: what does a caller get, and from where in the class tree?
Hint
Your child only needs __init__ (call super().__init__(name)) and api. Without the override, the call falls through to Share.api and comes back as a 404 that names the missing operation. That is the root default doing its job, and serve() never changed.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.