Roadmap¶
From an empty repo to a polished, PyPI-published v1.0 — sequenced by value-to-effort and dependency order, foundation before features.
Status (1.0 shipped): this document is now a "how it was built" history. Every milestone below — v0.1 through v0.9 plus the HTTP/2 and HTTP/3 transport tiers — shipped in 1.0. servery 1.0 delivers the full HTTP/1.1 core, a pure-stdlib HTTP/2 backend (
--http2), and an optional HTTP/3 tier via theservery[http3]aioquic extra (--http3). Read the milestones as the delivered build sequence, not as pending work. The tiered transport model is recorded indocs/TRANSPORTS.md.
This roadmap is governed by PRINCIPLES.md (Principle 0 above all: zero
third-party dependencies in the core, pure Python stdlib, forever — the opt-in
HTTP/3 tier is the one explicit, gated extra) and scoped by VISION.md. Every
milestone is independently shippable. Each lists a goal, in scope, out of
scope (for now), exit criteria, and the primary stdlib modules involved.
Verified design facts that shape the order (see REFERENCES.md):
- Range is not free. Stdlib
send_head()always returns200with the fullContent-Length. servery must implementRange/206itself. cgiis gone (3.13). Multipart upload parsing is hand-rolled — the single trickiest "easy" feature, so it gets its own late milestone.- A lot is free. Threading, HTTPS server class,
translate_pathtraversal protection,If-Modified-Since/304, index files, MIME guessing, dual-stack bind. We subclass and extend; we do not reinvent.
Definition of Done (every milestone must satisfy ALL of these)¶
A milestone is not "shipped" until:
- [ ] Zero deps verified.
pyproject.tomldeclares no runtime dependencies; a CI check fails the build ifdependenciesis non-empty or anything butserveryis importable from a clean install. - [ ] Tests pass via stdlib
unittest. New behavior covered byunittest-based tests (usinghttp.client/urllib.requestagainst a liveThreadingHTTPServeron an ephemeral port). No third-party test runner. Coverage measured with stdlib facilities where used. RFC-conformance cases for any standards behavior in the milestone (theSTANDARDS.md§4 corpus — e.g. the conditional-ladder E9–E13, theHost400E15, thenosniff/escaping E18, theContent-DispositionE19, the keep-alive framing E21) are encoded as part of the suite. - [ ] Runs on every supported CPython (3.13 and each newer non-EOL release), exercised in CI on Linux, macOS, and Windows.
- [ ] Safe defaults intact. Still binds
127.0.0.1by default; path-traversal and symlink-escape protections still hold (regression test present); no new feature weakens them or turns risky behavior on without an explicit flag. - [ ] Docs updated. README usage, CLI
--help, and any new flag documented in the same change. Public API docstrings current. - [ ] CLI ↔ library parity. Anything the new flag does is reachable from the Python API; the CLI only parses argv into config (Principle 4).
- [ ] Friendly startup/failure. Startup banner reflects new state (e.g. auth on, TLS on, the no-TLS-auth warning); errors say what to do.
- [ ] Changelog entry written; version bumped per the policy below.
Milestone overview¶
All milestones below are delivered in 1.0 (the "Status" column reflects the
shipped reality). The HTTP/2 and HTTP/3 transport tiers — originally backlogged as
out-of-scope — also landed in 1.0 (see docs/TRANSPORTS.md).
| Ver | Goal (one line) | Headline exit criterion | Status |
|---|---|---|---|
| v0.1 | Walking skeleton: installable, runnable, rich listing, safe bind | python -m servery shows size/mtime/dir-first listing on localhost |
Shipped |
| v0.2 | Sortable + searchable listing (Apache ?C=&O=) |
Clicking a column re-sorts JS-free; ?q= filters |
Shipped |
| v0.3 | Range + conditional requests (ETag/If-* ladder) + zero-copy |
curl -r returns 206; full If-* precedence → 304/412; sendfile zero-copy |
Shipped |
| v0.4 | TLS (user cert/key + ad-hoc self-signed) | --tls-cert/--tls-key serve HTTPS via SSLContext; --tls-self-signed mints a zero-dep self-signed cert at startup (_certgen.py) |
Shipped |
| v0.5 | Basic Auth (+ hashed, constant-time, no-TLS warning) | --auth u:p gates access; wrong creds → 401; loud HTTP warning |
Shipped |
| v0.6 | Upload (opt-in, streamed, bounded, overwrite-off) | --upload accepts multipart to disk, bounded, no traversal |
Shipped |
| v0.7 | Archive download (zip / tar.gz) | ?archive=tar.gz streams a folder; zip option present |
Shipped |
| v0.8 | CORS + SPA fallback + cache flags | --cors, --spa, --cache<n> behave per conventions |
Shipped |
| v0.9 | Hardening & polish (logging, bounded concurrency, error pages, cross-platform) | Themed error pages; --max-workers; Windows-clean; high coverage |
Shipped |
| HTTP/2 | Pure-stdlib HTTP/2 tier (HPACK + framing + flow control) | --http2: ALPN h2 over TLS (+ h2c cleartext); h1.1 fallback |
Shipped |
| HTTP/3 | Optional HTTP/3-over-QUIC tier via the servery[http3] extra |
--http3: HTTP/3 over QUIC (aioquic), Alt-Svc from the TCP tiers |
Shipped |
| v1.0 | Stability + packaging + PyPI release | pip install servery from PyPI; API/CLI frozen for 1.x |
Shipped |
v0.1 — Walking skeleton (MVP)¶
Goal. The smallest end-to-end thing that is already better than
python -m http.server: installable, three entry points, and a directory
listing with sizes, dates, human-readable units, and directories first — bound
safely to localhost.
In scope:
- Project scaffolding: pure
pyproject.toml(no build-time third-party deps;requires-python = ">=3.13"),src/servery/layout,__main__.pysopython -m serveryworks, aserveryconsole-script entry point, and an importable public API (aserve()/config object + handler/server classes). - Subclass
SimpleHTTPRequestHandler+ThreadingHTTPServer; reusetranslate_pathverbatim (security-reviewed) and the dual-stack bind mixin. - Rich listing: override
list_directoryto render name, size (human-readable, e.g.2.4 KB), mtime (local, ISO-ish), dirs-first, case-insensitive sort. Inline server-rendered HTML/CSS viahtml.escape+string.Template;urllib.parse.quotelinks; light/dark viaprefers-color-scheme. Guard per-entryOSError(broken symlinks). - Switch MIME lookup to
mimetypes.guess_file_type(path-preferred, 3.13+). - HTTP/1.1 by default: set
protocol_version = "HTTP/1.1"(keep-alive on; honorConnection: close). Cheap one-liner that turns on persistent connections —FR-CONN-01. (Streamed/Content-Length-less bodies arrive in later milestones; framing audit travels with them.) - Secure-by-default headers from day one:
X-Content-Type-Options: nosniffon every response (FR-SEC-04), plus correct listing escaping —html.escape(name)withquote=Trueand control-char stripping in filenames (FR-SEC-06, hardeningFR-LIST-03). Security headers must be default-on, not bolted on later.--no-security-headersescape hatch. - Default socket timeout (
ServeryHandler.timeout, e.g. 30 s) as a safe default — Slowloris mitigation, zero cost (NFR-PERF-04);--timeoutto tune. - Safe-default bind 127.0.0.1;
--host 0.0.0.0is the explicit opt-in. - Basic CLI (stdlib
argparse):[directory],--host,--port,--bindalias,--timeout,--no-security-headers,--version. Friendly startup banner (bound address).
Out of scope (for now): sorting/search UI, Range, ETag/conditional ladder, TLS, auth, upload, archives, CORS/SPA, themes selector, hidden-file toggle, remaining security headers (CSP/Referrer-Policy/HSTS — land with their milestones).
Exit criteria:
pip install -e .thenpython -m servery,servery, andimport servery; servery.serve(...)all serve the cwd with the rich listing.- Listing shows size + mtime + dirs-first and renders correctly for an empty dir, a dir with subdirs, and a dir with a broken symlink (no crash).
- The status line reads
HTTP/1.1; two sequential requests reuse one keep-alive connection;Connection: closeis honored (FR-CONN-01). - Every response carries
X-Content-Type-Options: nosniff; a hostile filename ("><img …>.txt, and one with\r\n/\x00) renders fully escaped with no raw control bytes (FR-SEC-04,FR-SEC-06);--no-security-headersremoves the header. - Default bind is
127.0.0.1; a test asserts it does not bind0.0.0.0without the flag. - Path-traversal regression test passes (
.., encoded, absolute tricks all 404 or stay within root). - All Definition-of-Done boxes checked.
Primary stdlib modules: http.server, socketserver, os (scandir,
stat), html, string, urllib.parse, mimetypes, argparse, datetime.
v0.2 — Sortable & searchable listing¶
Goal. Make the headline listing sortable and filterable the way people
already expect — mirroring Apache mod_autoindex so it is familiar and JS-free
for the core path.
In scope:
- Server-side sort via the Apache
?C=&O=scheme:C=N|M|S(name / mtime / size),O=A|D(asc/desc). Column headers are self-referencing links that encode the next sort state (same column togglesA↔D; new column resets toA). Emit&, accept both∧separators. - Server-side
?q=substring filter (pure-server, no round-trip needed) plus an optional small inline JS filter box (still zero third-party dep — it is text we ship) for instant client-side narrowing. - Breadcrumbs;
--hiddentoggle to show dotfiles (default hide). IgnoreClient-style server flag to ignore client sort params (hardening toggle).
Out of scope (for now): recursive directory-size totals, version sort
(V=), P= glob filter (may revisit), theme selector UI.
Exit criteria:
- Clicking each column header re-sorts correctly and toggles order; verified with no JavaScript enabled (server-side).
?C=S&O=Dreturns entries largest-first;?q=fooreturns only matching entries; both compose.- Sort is stable and dirs-first is preserved within each order.
- Definition-of-Done satisfied.
Primary stdlib modules: os.scandir/DirEntry.stat, urllib.parse,
html, string.
v0.3 — Range + conditional requests + zero-copy¶
Goal. Make large files and media behave: resumable downloads and seeking, correct RFC 9110 conditional-request handling, and kernel zero-copy on the hot path. Pure handler work with no new config surface coupling — which is why it precedes TLS/Auth.
In scope:
- Override
send_head/file-send to honorRange: bytes=a-b: emitAccept-Ranges: bytesalways; for a valid range,206 Partial Content+Content-Range: bytes a-b/total+ boundedContent-Length,seek()+ bounded read. Handle suffix (bytes=-N) and open-ended (bytes=a-) forms;416for unsatisfiable. Single-range only (multipart byteranges out of scope). ETag(weak): emit a weakW/"<size>-<mtime_ns>"validator fromos.stat(FR-CACHE-02); quoted,W/-prefixed.- Full conditional-request ladder (
FR-COND-01): implement the RFC 9110 §13.2.2 precedence —If-Match/If-Unmodified-Since(→412) beforeIf-None-Match/If-Modified-Since(→304for GET/HEAD); ignoreIf-Modified-SincewhenIf-None-Matchis present;304carries no body and echoes validators (ETag/Date/Vary/Cache-Control). Keep the inheritedLast-Modified/If-Modified-Sincepath as the bottom rung. If-Rangegating (FR-COND-02): applyRangeonly whenIf-Rangematches (date-exact or strong-ETag); a weakETaginIf-Range→ full200.- Zero-copy via
socket.sendfile()(NFR-PERF-03): overridecopyfilefor the full-file200path to useself.connection.sendfile(source)(kernelos.sendfile, internal fallback) with a boundedcopyfileobjfallback; skip sendfile under TLS (ssl.SSLSocket). The206path keeps the bounded seek/read loop.
Out of scope (for now): multi-range responses, gzip response compression
(lands in v0.8 with the other header flags), the Cache-Control flag (v0.8),
the optional upload-write If-Match/If-Unmodified-Since guard (FR-COND-03,
revisited with upload in v0.6).
Exit criteria:
curl -r 0-99 <url>returns206, correctContent-Range, exactly 100 bytes; suffix and open-ended ranges work; an out-of-bounds range returns416.- A resumed/aborted download completes correctly across two range requests.
- The full conditional ladder is tested per
STANDARDS.md§4: failingIf-Match - present
If-None-Match→412(precedence);If-None-MatchbeatsIf-Modified-Since;If-None-Match: *→304;304has no body and echoesETag;Rangeis ignored when the conditional yields304; weak-ETagIf-Range→ full200. - A plain-HTTP full-file download uses
sendfile(asserted by guard test); an HTTPS download of the same file falls back to the buffered copy without attemptingsendfile. - Definition-of-Done satisfied.
Primary stdlib modules: http.server, os/io (seek, bounded read,
sendfile), socket (sendfile), ssl (TLS guard), hashlib, email.utils
(HTTP-date), shutil.
v0.4 — TLS (user-provided cert/key)¶
Goal. Serve over HTTPS using the modern stdlib recipe with user-provided
cert/key. (Originally this milestone assumed the stdlib could not mint a
self-signed cert and ruled generation out. That assumption was later proven
false — pure-Python RSA+DER+PKCS#1 in _certgen.py mints one with zero
deps — and ad-hoc self-signed generation now ships via --tls-self-signed, added
to this milestone's scope below.)
In scope:
--tls CERT KEY(and--tls-cert/--tls-key, plus--tls-password-filefor an encrypted key) wired through the server.- Build context via
ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)+ctx.load_cert_chain(...)+ ALPN["http/1.1"]in the core (the HTTP/2 tier later addsh2to the ALPN list when--http2is enabled — see the HTTP/2 milestone anddocs/TRANSPORTS.md; the core advertises onlyhttp/1.1). Neverssl.wrap_socket. Leave the default cipher suite and session tickets;minimum_version = TLSv1_2. - HSTS under TLS (
FR-SEC-05): emitStrict-Transport-Security(e.g.max-age=63072000; includeSubDomains,preloadoff) only when serving HTTPS — never on plain HTTP.--hstsmay tune/force it. - Startup banner reflects
https://and the bound address. --tls-self-signed(zero-dep, pure stdlib): mint an RSA-2048 self-signed cert at startup via_certgen.py(pow/hashlib/secrets+ a hand-rolled DER encoder + PKCS#1 v1.5 signing — nocryptography, noopensslbinary, noctypes), write it to a 0600 temp dir, load via OpenSSL throughssl, delete. For opportunistic encryption on a dev box / LAN only — clients see an "untrusted certificate" warning; it is not a trust anchor. Mutually exclusive with--tls-cert; emits a startup warning.--tls-helpstill prints anopensslrecipe for the user-cert path.
Out of scope (for this milestone): mTLS / client-cert verification (deferred —
see backlog); publicly-trusted / auto-renewed certs (ACME / Let's Encrypt) — a
future optional servery[acme] extra (mirrors the servery[http3] precedent),
not implemented; the non-TLS security headers CSP/Referrer-Policy (land in
v0.8 with CORS).
Exit criteria:
- With a test cert/key fixture,
--tlsserves HTTPS; an HTTPS client retrieves a file and a listing successfully. - A missing/invalid cert or key fails fast with a clear, actionable error.
- Plain-HTTP behavior is unchanged when
--tlsis absent. - Definition-of-Done satisfied.
Primary stdlib modules: ssl, http.server (HTTPS server class), socket.
v0.5 — Basic Auth (single shared credential, hashed, constant-time)¶
Goal. Gate the server behind a single shared credential — safely: never plain
==, and loud when used without TLS.
In scope:
--auth USER:PASS(single shared credential). ParseAuthorization: Basic <b64>(base64.b64decode); compare both username and password withhmac.compare_digest(constant-time). On miss:401+WWW-Authenticate: Basic realm="servery".- Hashed form: accept a hashed credential (e.g.
user:sha256:<hexdigest>, miniserve-parity) so the plaintext password need not sit on the command line; compare digests withhmac.compare_digest. Pick and document the format up front. - Loud no-TLS warning: if auth is enabled without
--tls, print a prominent startup warning that Basic Auth over HTTP sends credentials effectively in the clear. Never imply auth-over-HTTP is private.
Out of scope (for now): multiple users / auth files, roles/sessions/accounts,
salted KDF as the only option (raw SHA for parity is acceptable; a salted
pbkdf2/scrypt form may be added later), upload-only-vs-all auth split
(revisit when upload lands).
Exit criteria:
- No/invalid credentials →
401withWWW-Authenticate; correct credentials →200. Hashed-credential form authenticates equivalently. - A timing-independent comparison is used (code review confirms no
==on secrets); a test asserts both username and password are checked. - Enabling auth without TLS emits the loud warning (captured in a test).
- Definition-of-Done satisfied.
Primary stdlib modules: base64, hmac, hashlib, secrets,
http.server.
v0.6 — Upload (opt-in, streamed, bounded, overwrite-off)¶
Goal. Let the other side send files back — safely. This is the trickiest
"easy" feature (no cgi since 3.13), so it lands after the security primitives
(auth, TLS) it should usually run behind.
In scope:
--upload(off by default) enablingdo_POSTto an upload target directory.- Multipart parsing without
cgi: parse theContent-Typeboundary and split the body into parts; per-part headers viaemail.parser/email.message; simple url-encoded bodies viaurllib.parse.parse_qsl. Read the body offrfilebounded byContent-Length. - Streamed to disk, bounded: write each part to a
tempfilethen atomicos.renameinto place (Droopy/uploadserver pattern). Enforce a configurable max upload size; reject oversize with413. - Safety: sanitize/refuse traversal in submitted filenames (no
.., no absolute paths, basename only); overwrite OFF by default (409/rename on collision;--overwriteto opt in); never write outside the upload target. - Minimal upload UI (an upload control in the listing when enabled).
Out of scope (for now): mkdir / delete / chmod / move file-ops, drag-drop fanciness, resumable/chunked uploads, concurrency caps beyond the threading default, full streaming multipart for pathological huge single parts (bounded + temp-file is the contract; document the memory boundary honestly).
Exit criteria:
- A multipart POST writes the file to the target dir with correct bytes; a url-encoded form body is parsed too.
- Upload is off without
--upload(POST →405/403); a traversal filename is rejected; an oversize upload returns413; a collision does not overwrite unless--overwrite. - Uploaded data is streamed to a temp file and atomically renamed (verified no partial file is visible at the destination on failure).
- Definition-of-Done satisfied.
Primary stdlib modules: email.parser/email.message, urllib.parse,
tempfile, os (rename, path), shutil, http.server.
v0.7 — Archive download (zip / tar.gz)¶
Goal. Download a whole folder as one file, streamed so memory stays bounded.
In scope:
- A download trigger on directories (e.g.
?archive=tar.gz/?archive=zip, with a button in the listing). - tar.gz:
tarfile.open(fileobj=wfile, mode="w|gz")— genuinely streaming straight to the socket (no seek, no temp file). Preferred path. - zip:
zipfile.ZipFilewithZIP_DEFLATED; stream chunked to the socket (or temp-file-then-stream) to avoid an in-RAM full archive. Since streamed archives cannot setContent-Length, use chunked transfer / connection-close appropriately (the keep-alive framing corollary ofFR-CONN-01). Content-Disposition(FR-DISP-01):attachmentwith both an ASCII-sanitizedfilename="…"fallback and an RFC 8187filename*=UTF-8''<pct-encoded>extended form for non-ASCII directory names; sanitize the basename first (strip CR/LF/") against header injection.- Respect path-traversal/symlink rules while walking (
os.walk); skip entries that escape the root.
Out of scope (for now): plain .tar (low value; revisit), per-file
selection, archive of arbitrary cross-directory selections, compression-level
tuning.
Exit criteria:
?archive=tar.gzof a folder streams a valid gzip tar that extracts to the original tree;?archive=ziplikewise extracts correctly.- An archive of a directory named
€carriesContent-Disposition: attachment; filename*=UTF-8''%e2%82%ac…plus an ASCIIfilename=fallback (FR-DISP-01). - A streamed
tar.gzover a keep-alive connection is delimited by chunked orConnection: closeand delivers a complete, non-hanging body. - Memory stays bounded for a large tree (no full-archive buffering for tar.gz; documented bound for zip).
- Symlinks/paths that would escape the root are not included.
- Definition-of-Done satisfied.
Primary stdlib modules: tarfile, zipfile, os.walk, http.server.
v0.8 — CORS + SPA fallback + cache/header flags¶
Goal. The static-site/dev-server conveniences, as a cluster of small header- and routing-level flags that share machinery.
In scope:
--cors→Access-Control-Allow-Origin: *(+ methods/headers); handleOPTIONSpreflight (do_OPTIONS→204).--spa/--singleSPA fallback: not-found, non-file paths are internally rewritten (no redirect) toindex.html; also honor a404.htmlif present. Guard against rewriting asset/API-looking paths.-c<n>/-c-1Cache-Control (http-server convention):max-age=n, with-c-1as the explicit "no cache" sentinel.- Optional clean URLs (opt-in): serve
/aboutfrom/about.html, 301-redirect the.htmlform to the clean form. - Remaining security headers (
FR-SEC-05):Content-Security-Policyscoped to servery-generated pages only (listings/error pages — never on served user.html), defaultdefault-src 'none'; img-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; andReferrer-Policy: no-referreron all responses. Default-on; suppressible with--no-security-headers. (nosniffalready shipped in v0.1; HSTS-under-TLS in v0.4.) - gzip response compression (opt-in): honor
Accept-Encoding: gzip/deflatefor text types, setContent-Encoding, skip already-compressed types, emitVary: Accept-Encoding.gzip/deflateare always stdlib;zstdis 3.14+ only (compression.zstd) and MUST be probed/gated, never advertised on 3.13. - Custom headers via the base
extra_response_headers/-Hhook (extra CORS, etc.).
Out of scope (for now): per-origin CORS allowlists, route-prefix / random route (low priority; backlog), healthcheck endpoint (backlog).
Exit criteria:
--corssets the header and answers preflightOPTIONSwith204.- With
--spa, a deep non-file path returnsindex.htmlbody with a200(no redirect); a present404.htmlis served on miss. - A directory-listing response carries the default
Content-Security-PolicyandReferrer-Policy: no-referrer; a served user.htmlfile does not carry the CSP;--no-security-headersremoves both (FR-SEC-05). -c3600setsCache-Control: max-age=3600;-c-1disables caching.- gzip path returns
Content-Encoding: gziponly for eligible types and decodes to the original bytes. - Definition-of-Done satisfied.
Primary stdlib modules: http.server, gzip, urllib.parse, os.path.
v0.9 — Hardening & polish¶
Goal. Turn a feature-complete tool into a trustworthy one: good logs, good error pages, cross-platform correctness, and real test depth.
In scope:
- Logging through
logging(FR-LOG-05/06/07): route request logging throughlogging.getLogger("servery")with a libraryNullHandler(library quiet, CLI loud — CLI attaches a TTY-awareStreamHandler); capture status AND real byte count (not the stdlib's-); swallow expected client disconnects (BrokenPipeError/ConnectionResetError/TimeoutError) without tracebacks.--quiet, plus an opt-in access log in Common/Combined Log Format (--access-log[=FORMAT]/--log-format). Clear startup banner summarizing host/port, TLS on/off, auth on/off (+ no-TLS warning), upload on/off. - Bounded concurrency (
NFR-PERF-04): optional--max-workerscap via aconcurrent.futures.ThreadPoolExecutor(overrideprocess_request); default stays unbounded thread-per-connection. (The default socket--timeoutalready shipped in v0.1.) - Error pages: themed, escaped HTML for
401/403/404/413/416/500matching the listing's look; never leak filesystem paths or stack traces to clients. - Cross-platform: Windows path quirks (drive letters, separators, reserved names), case-insensitive filesystem behavior, correct mtime/size formatting; CI matrix proves Linux/macOS/Windows parity.
- Security pass: dedicated tests for path traversal, symlink escape
(
os.path.realpathcontainment +--no-symlinks), upload filename sanitation, constant-time auth; a written threat-model note in docs. - Test depth: raise coverage across handlers; add fixtures for large files,
broken symlinks, unusual filenames (unicode, spaces,
%), and concurrency. - Docs: complete README with the one-liners from the Vision, a CLI reference,
an embedding/
import serveryexample, and the honest "not for production" posture.
Out of scope (for now): new user-facing features; performance micro-tuning beyond avoiding obvious O(n²)/full-buffer patterns.
Exit criteria:
- Error responses are themed, escaped, and leak nothing; verified per status code.
import serveryis silent until the embedder adds a handler;--access-logemits Common/Combined Log Format with a real byte count; a mid-download client disconnect produces no traceback (FR-LOG-05/06/07).--max-workers Ncaps concurrent handlers atN(excess queue); default is unbounded (NFR-PERF-04).- Full CI matrix (all supported CPython × Linux/macOS/Windows) green.
- Security regression suite passes (traversal, symlink, upload sanitation, timing-safe auth).
- Docs cover every flag and the public API; the Vision one-liners all work verbatim.
- Definition-of-Done satisfied.
Primary stdlib modules: logging/http.server logging, html, os.path,
os (realpath), unittest, platform.
v1.0 — Stability & release¶
Goal. Freeze a small, stable surface and ship it to PyPI with the zero-dep promise mechanically guaranteed.
In scope:
- API/CLI freeze: finalize public names, flag spellings, config object, exit codes, and the hashed-credential / upload-target formats. Document what is public (stable) vs internal.
- Packaging: complete
pyproject.tomlmetadata (classifiers, license,requires-python = ">=3.13", console-script),LICENSE, long-description from README, reproducible sdist + wheel built with a stdlib-friendly backend, no runtime dependencies asserted by a release gate. - Release engineering: tagged release, CHANGELOG, CI job that builds and
publishes to PyPI; a post-publish smoke test that
pip install serveryin a clean venv pulls only servery and runs. - Quality bars: test coverage target met; all supported CPythons green; security suite green.
Out of scope (for now): anything in the north-star backlog.
Exit criteria:
pip install serveryfrom PyPI in a clean environment installs nothing but servery and runs all three entry points.- A release gate fails if any runtime dependency is introduced.
- Version is
1.0.0; public API/CLI documented as frozen for the 1.x line. - Definition-of-Done satisfied.
Primary stdlib modules: unittest; packaging via standards-based
pyproject.toml (build backend is tooling, not a servery runtime dep).
Release & versioning policy¶
servery follows Semantic Versioning.
- Pre-1.0 (
0.y.z): each0.yminor is a shippable, usable increment, but the public API and CLI flags may change between minors as the design settles.0.y.zpatches are bug-fix only. Breaking changes are called out in the changelog; we deprecate loudly when we can, but pre-1.0 does not promise stability. - 1.0 promise: the public Python API and the CLI flag surface are
stable for the 1.x line. Backward-incompatible changes require a
2.0. New features arrive in1.yminors (additive, opt-in, safe-default-preserving); fixes in1.y.zpatches. - The one promise that never has a version: zero third-party runtime
dependencies in the core, forever.
pip install serveryinstalls servery and nothing else, and the default code path imports only the stdlib (the HTTP/2 tier is itself pure-stdlib). The single, explicit exception is the opt-in HTTP/3 tier:pip install servery[http3]pulls inaioquic, imported only under--http3(PRINCIPLES.md§0 refinement;docs/TRANSPORTS.md). It is not a feature with a version; it is the contract. A release gate enforces it on every build. - Python floor moves only deliberately, in a minor release, with a changelog note — never silently (Principle 3). We test every supported, non-EOL CPython from the floor (3.13) up.
North-star backlog (explicitly deferred / maybe-never)¶
Parked ideas, each with the reason. Nothing here blocks 1.0.
| Idea | Status | Why parked |
|---|---|---|
| QR code for the share URL | Maybe-never | No stdlib QR encoder. Hand-rolling Reed-Solomon/masking is large and error-prone; faking it betrays the zero-dep simplicity. Drop, or only ever as an optional extra. |
| Full Markdown README rendering | Maybe-never | No stdlib Markdown parser. Most we'll do is escaped plaintext <pre>. A reduced in-house subset renderer is possible but not GFM-fidelity and not worth the surface. |
WebDAV (read-only PROPFIND) |
Deferred | Zero-dep feasible (xml.etree for 207 multistatus) but laborious real protocol work; low payoff for the file-server lane. Revisit only on real demand. |
| mTLS (client-cert verification) | Deferred | Zero-dep feasible (load_verify_locations + CERT_REQUIRED); deferred as advanced/niche beyond the single-credential auth model. |
| Publicly-trusted / auto-renewed certs (ACME / Let's Encrypt) | Maybe (optional extra) | The one TLS capability that warrants a dependency: the full ACME protocol + robust long-lived-key crypto + a public domain reachable on :80/:443 is production-public-web-server territory (Caddy's lane), at the edge of servery's dev/LAN scope. Would be a future servery[acme] extra (e.g. cryptography + an ACME client), mirroring servery[http3] = aioquic. Not implemented. (Ad-hoc self-signed certs are already in the core, zero-dep, via --tls-self-signed.) |
| Dynamic handlers — CGI / WSGI / ASGI | Planned (opt-in, off by default) | Host the operator's own script/app via the standard interfaces, all pure stdlib (subprocess / wsgiref / asyncio), behind explicit --cgi / --wsgi / --asgi flags, gated like --upload. servery still defines no routes or middleware (that stays Never, below) — it hosts an interface, it does not become a framework. Feasibility prototyped (all three work zero-dep); phased plan, 3.14+ design, security model, and per-phase external test harnesses in DYNAMIC.md. |
| Themes selector UI | Deferred | prefers-color-scheme covers the 90% case in v0.1; an explicit light/dark selector is polish, not core. |
| Recursive directory-size totals | Deferred | Useful but can be O(n) expensive on big trees; needs a bounded/cached design before it's worth it. |
| mkdir / delete / chmod / move file-ops | Deferred | Each widens the write surface and the security/threat model; upload alone covers the core "send me a file" need. Add only with strong opt-in gating. |
| Route-prefix / random-route / healthcheck | Deferred | Trivial individually but each is a flag with permanent cost (Principle 6); bundle later only if users ask. |
| Multi-user auth / auth-file | Deferred | The model is a single shared credential (Vision non-goal: no accounts/roles/sessions). A multi-entry file is the most we'd consider, and only on demand. |
--once / serve-N-times lifecycle (woof-style) |
Maybe | Cute and zero-dep, but niche; backlog until requested. |
| User-defined routes / app endpoints / middleware | Never | Framework lane. Hard non-goal (Vision §5, Principle 2). This is the line that keeps servery finishable. |
Risk register¶
| Risk | Likelihood × Impact | Mitigation |
|---|---|---|
Hand-rolled multipart (no cgi) is subtly wrong — boundary edge cases, CRLF handling, header parsing |
Med × High | Parse part headers with email.parser (don't reinvent header parsing); bound everything by Content-Length; a focused fuzz/fixture suite of real browser multipart bodies (Chrome/Firefox/curl) before shipping v0.6; reject malformed boundaries strictly. |
| Large-file / large-upload memory blowup | Med × High | Downloads: Range + seek/bounded reads, never buffer whole files. Archives: stream tar w|gz to the socket; chunked zip. Uploads: stream to tempfile + atomic rename, enforce a max-size with 413. Document the one honest memory bound (pathological single huge multipart part). |
| Path-traversal / symlink-escape bypass | Low × Critical | Reuse stdlib translate_path verbatim (security-reviewed, includes // open-redirect fix); add os.path.realpath containment + --no-symlinks; sanitize upload filenames to basename, reject ../absolute; dedicated security regression suite (v0.9) run every CI. |
| Windows path quirks — drive letters, separators, reserved names, case-insensitivity | Med × Med | Use os.path/pathlib consistently (no string slicing of paths); CI matrix includes Windows from v0.1; tests for reserved names and unusual filenames; never construct paths by concatenation. |
| Timing/credential leak in auth | Low × High | Always hmac.compare_digest on both username and password; never == on secrets; code-review gate + a test asserting both fields are checked; generate any nonces with secrets. |
| Basic Auth over plain HTTP misunderstood as private | Med × Med | Loud startup warning when auth is on without TLS; docs are explicit that base64 ≠ encryption; banner restates TLS state. |
| Accidental dependency creep | Low × Critical (breaks the promise) | Release gate that fails the build if dependencies is non-empty or anything but servery imports from a clean install; reviewer checklist item; Principle 0 is the tie-breaker on every design call. |
Streamed archive/gzip can't set Content-Length → client confusion |
Low × Med | Use chunked transfer-encoding / connection-close deliberately; test that clients (curl, browsers) receive complete archives; document the streaming contract. |
Keep-alive framing correctness (HTTP/1.1 default reuses the socket; a wrong/missing Content-Length corrupts the next request) |
Med × High | Framing audit on every body-writing path (files/ranges send exact Content-Length; listings/errors too); streamed/Content-Length-less bodies (tar.gz, zip) use chunked or Connection: close; a keep-alive test asserts a complete, non-hanging body and that a second request on the same socket is not corrupted (STANDARDS.md E9/E21; FR-CONN-01). |
sendfile/TLS interaction (ssl.SSLSocket cannot zero-copy; os.sendfile absent on some platforms) |
Low × Med | copyfile skips sendfile when isinstance(self.connection, ssl.SSLSocket) and goes straight to the bounded buffered copy; socket.sendfile also falls back internally for non-regular files; a guard test asserts HTTPS uses the fallback and plain HTTP uses zero-copy where available (NFR-PERF-03; BEST-PRACTICES.md §2.1). |
| Concurrency races on upload (same target file, atomic visibility) | Low × Med | Temp-file + atomic os.rename; overwrite-off by default with collision handling; tests under concurrent POSTs. |
| Scope creep toward "a worse Flask" | Med × High (to the project's identity) | Every feature runs the §7 scope rubric; the north-star backlog records the nos with reasons so they aren't re-litigated; "the default answer is no." |