Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
d5d1aa4
T050: the plain-documents fixture, spread across the six stations (pl…
brettheap Sep 27, 2026
834f8ea
T052: 5.4, declare the generator seam, and register openDox's own gen…
brettheap Sep 27, 2026
4ca45d2
T052: each declared input optional; a default's generation recorded o…
brettheap Sep 27, 2026
09560ee
Merge remote-tracking branch 'origin/main' into feat/plain-documents-…
brettheap Sep 27, 2026
f52b111
T051: the malformed fixture, one title-and-summary-are-text violation…
brettheap Sep 27, 2026
ac553e4
Merge main (T036 #52, T037 #55 -> 2d116415) into T054's branch
brettheap Sep 27, 2026
5538086
Merge T050's fixture (openDox-code#53, feat/plain-documents-fixture 0…
brettheap Sep 27, 2026
b1946db
Merge T051's fixture (openDox-code#56, feat/malformed-fixture f52b111…
brettheap Sep 27, 2026
25fe752
T054 (work in progress): openDox's small neutral projection over Corp…
brettheap Sep 27, 2026
0f42f67
T054: the projection's tests, the holder's two rulings, and the displ…
brettheap Sep 27, 2026
b2f9222
T054: compare the commit date as a time, as git spells it either way …
brettheap Sep 27, 2026
c0747e9
T054: an empty topics header is a declaration; T050's docstring says …
brettheap Sep 27, 2026
5a6fe63
T054: a word is a run of letters beside digits too; a pin labels the …
brettheap Sep 27, 2026
21dfdb7
Merge main (T036 #52, T037 #55 -> 2d116415) into T052's branch
brettheap Sep 27, 2026
bce09c5
T052: each registration keeps its own records at the generator seam
brettheap Sep 27, 2026
97b5b01
T052: leave point 6 of the seam test's docstring as T054 edits it
brettheap Sep 27, 2026
8d507c5
T052: current() reads the registration once
brettheap Sep 27, 2026
e8a733d
Merge T052's head (openDox-code#54, build/034-p2g-t052-generator-seam…
brettheap Sep 28, 2026
c27eac3
T055: serve and generate standalone (5.5, 4.3 part) (plan 034)
brettheap Sep 28, 2026
ea49c42
T055: the canonical path meets the hidden-name rule; a token variable…
brettheap Sep 28, 2026
bfb9c47
T055: serve.main hands each data-source option over as given; droppin…
brettheap Sep 28, 2026
244d7b7
T055: the unavailable-validator warning says which of its two cases h…
brettheap Sep 28, 2026
19c2678
T055: the writer refuses what JSON cannot carry; one request serves t…
brettheap Sep 28, 2026
8c09d74
T055: the source arm resolves its entry once, and asks for the path b…
brettheap Sep 28, 2026
f4ef63d
T055: the default writer replaces the snapshot atomically
brettheap Sep 28, 2026
8defc37
Merge main 2d116415 (T037, #55) into T050's branch
brettheap Sep 29, 2026
521de95
T050: the docstring says the required check collects this suite
brettheap Sep 29, 2026
7028f61
T050: the unrelated source shares no topic with the pair, not just no…
brettheap Sep 29, 2026
4ba410f
T050: compare each source's whole derived topic set; pin the fixture'…
brettheap Sep 29, 2026
44dcc3a
Merge main 2d116415 (T037, #55) into T051's branch
brettheap Sep 29, 2026
fd55f9f
Merge T052's head (openDox-code#54, build/034-p2g-t052-generator-seam…
brettheap Sep 29, 2026
ba6adf5
Merge T050's head (openDox-code#53, feat/plain-documents-fixture 4ba4…
brettheap Sep 29, 2026
07d2b1b
Merge T051's head (openDox-code#56, feat/malformed-fixture 44dcc3a1) …
brettheap Sep 29, 2026
d97a4aa
Merge main dc3765dd (T050 landed, #53) into T054's branch
brettheap Sep 29, 2026
2f515c5
Merge main dc3765dd (T050 landed, #53) into T052's branch
brettheap Sep 29, 2026
32bd4fa
Merge T052's head (openDox-code#54 2f515c57) into T054's branch
brettheap Sep 29, 2026
8e7da4a
T054: openDox's own scaffold carries the small neutral field set
brettheap Sep 29, 2026
8cbb1ea
Merge T054's head (openDox-code#57, build/034-p2p-t054-neutral-projec…
brettheap Sep 29, 2026
d7aa9d8
T055: an empty --project-register or --possibles is refused, fail closed
brettheap Sep 29, 2026
687d37b
T055: a refresh keeps the snapshot's permissions
brettheap Sep 29, 2026
96f18c4
T055: the source arm's two lookups are made under the registry's lock
brettheap Sep 29, 2026
e3ef506
T055: the arm confines its entry in hand, reads take the lock, the re…
brettheap Sep 29, 2026
1a60367
Merge main fa8862cc (T051 and T052 landed, #56 and #54) into T054's b…
brettheap Sep 29, 2026
03e06cc
T054: a scaffold's leading title and summary are one header line each
brettheap Sep 29, 2026
65c943f
T056: the standalone generate path, end to end, and the served URL is…
brettheap Sep 29, 2026
1597511
T056: the lone-openDox child process becomes a test helper, for T058'…
brettheap Sep 30, 2026
50b0d42
T054: one emptiness rule in both vocabularies; the commit date uses t…
brettheap Sep 30, 2026
d7954fc
T054: the projection reads keys as paths only where the adapter decla…
brettheap Sep 30, 2026
42a08a3
T056: an interrupted child that will not stop is killed before its pi…
brettheap Sep 30, 2026
92a9167
Merge T054's final head (openDox-code#57, d7954fc6) into T055's branch
brettheap Sep 30, 2026
814516b
Merge main a691e4e4 (T054 landed, #57) into T055's branch
brettheap Sep 30, 2026
5a26532
Merge T055's head (openDox-code#59 814516b7) into T056's branch
brettheap Sep 30, 2026
e939c31
T056: the undeclared stage: value is looked for inside every string, …
brettheap Sep 30, 2026
c2a8ad9
Merge main 8ec08e91 (T057 landed, #58) into T055's branch
brettheap Sep 30, 2026
e357477
Merge T055's head (openDox-code#59 c2a8ad9f) into T056's branch
brettheap Sep 30, 2026
183b40f
T055: a regenerate passes the project register only when one is set
brettheap Sep 30, 2026
caa01ca
T056: the "serving until interrupted" line is read while the server runs
brettheap Sep 30, 2026
38761c7
Merge T055's head (openDox-code#59 183b40fc) into T056's branch
brettheap Sep 30, 2026
a6e953c
Merge main (fa140875, T055 landed as openDox-code#59) into T056's branch
brettheap Sep 30, 2026
149d729
T056: the harness gives the child Ctrl-C as at a terminal, whatever t…
brettheap Sep 30, 2026
a7bda06
T056: the stage notice's six keys are checked as one rendered list
brettheap Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/opendox/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,13 @@ def cmd_generate_and_open(args: argparse.Namespace, *, opener=webbrowser.open) -
url = serve_mod.server_url(httpd, "/index.html")
print(f" serving {url}")
print(f" snapshot {serve_mod.server_url(httpd, '/snapshot.json')}")
print(url) # the URL is ALWAYS printed on its own line
# The URL is ALWAYS printed on its own line, AND FLUSHED (plan 034 T056).
# Where standard output is a pipe or a file, Python buffers it by block,
# and the process is about to block in `serve_forever()`. So without the
# flush, a wrapper reading this line never sees it while the server runs,
# and it cannot learn an ephemeral port or tell that the server started.
# Measured at openDox-code#59 e3ef506a: zero lines in 20 s on a pipe.
print(url, flush=True)

if not args.no_open:
try:
Expand All @@ -610,7 +616,7 @@ def cmd_generate_and_open(args: argparse.Namespace, *, opener=webbrowser.open) -
httpd.server_close()
return 0

print(" serving until interrupted (Ctrl-C to stop)")
print(" serving until interrupted (Ctrl-C to stop)", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
Expand Down
7 changes: 6 additions & 1 deletion src/opendox/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2216,7 +2216,12 @@ def serve(
checkout_root=checkout_root))
httpd = build_server(web_dir, snapshot_path, checkout_root, host=host,
port=port, quiet=quiet, actor=actor, **build_kwargs)
print(f"serving ideation dashboard at {server_url(httpd, '/index.html')}")
# FLUSHED before the process blocks (plan 034 T056): where standard
# output is a pipe or a file it is block-buffered, so an unflushed line
# never reaches a wrapper while the server runs, and the wrapper cannot
# learn an ephemeral port or tell that the server started.
print(f"serving ideation dashboard at {server_url(httpd, '/index.html')}",
flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
Expand Down
245 changes: 245 additions & 0 deletions tests/standalone_child.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
"""A lone openDox, as a CHILD PROCESS: `python -m <module> ...` with neither
sibling importable (plan 034's T056, and T058's F7.2 case after it).

A test that means "the verb, the way a user runs it" runs the verb in a process
of its own, through `python -m`, as #1144's falsifiers are written. This module
builds that child and reads it:

* NEITHER SIBLING IS IMPORTABLE. The child's interpreter loads a
`sitecustomize` from a directory put first on `PYTHONPATH`. It installs a
meta-path finder that refuses `openxdox`, `ideation_dashboard`, `doc_health`
and `corpus_adapter_openxfactory`, whatever is installed, and drops any of
them that a `.pth` file imported before it ran. It records every refused name
in a log (`Child.refused()`). A case asserts that the log is EMPTY: a refused
import that some `except ImportError` swallowed would otherwise pass as a
degraded run.
* ITS STANDARD OUTPUT IS A PIPE WITH PYTHON'S DEFAULT BUFFERING, as any
wrapper that reads it sees it. `PYTHONUNBUFFERED` is taken out of the
child's environment on purpose, so a line the child prints and does not
flush before it blocks is a line a case never reads.
* It is read on threads while it runs (`Child.wait_for_line`), so a server that
never exits can still be asked where it serves, and then interrupted
(`Child.interrupt`, SIGINT, as Ctrl-C sends).
* CTRL-C REACHES IT AS IT WOULD AT A TERMINAL, whatever the runner's own
disposition. The same `sitecustomize` sets SIGINT back to Python's
KeyboardInterrupt handler. A runner started as a background job
(`nohup pytest ... &`) has SIGINT ignored, and every child would inherit
that, so an interrupted server would time out and the case would report the
runner, not the server (measured at openDox-code#66 a6e953ce). A child that
ignores SIGINT ITSELF, after startup, still does, and is killed at the
deadline.

`fresh_repository()` is #1144's preamble: a fixture copied into a FRESH git
repository and committed as the fixture's own identity.

A helper module, not a test module: it holds no case. A CREATED FILE, with no
carve-manifest row (RULED OQ-C).
"""

from __future__ import annotations

import os
import queue
import re
import shutil
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent

#: The four packages a neutral openDox must run without (#1144's F2.1).
SIBLINGS = ("openxdox", "ideation_dashboard", "doc_health",
"corpus_adapter_openxfactory")

#: How long a child may take to print what a case waits for. Generation over a
#: fixture takes about a second; the margin is for a loaded CI runner.
START_DEADLINE_SECONDS = 120.0

#: How long a child may take to stop once interrupted, or to finish a run.
STOP_DEADLINE_SECONDS = 30.0
RUN_DEADLINE_SECONDS = 300.0

#: The environment variable naming the child's refused-import log.
REFUSED_LOG_ENV = "OPENDOX_STANDALONE_CHILD_REFUSED"

#: The `sitecustomize` every child loads.
_BLOCKER = f'''\
import os
import signal
import sys

# Ctrl-C as at a terminal: a runner started as a background job ignores
# SIGINT, and an ignored signal is inherited across exec (tests/standalone_child.py).
signal.signal(signal.SIGINT, signal.default_int_handler)

_SIBLINGS = {SIBLINGS!r}

for _name in list(sys.modules):
if _name.split(".")[0] in _SIBLINGS:
del sys.modules[_name]


class _RefuseTheSiblings:
"""Neither sibling is importable in this process (plan 034 T056)."""

def find_spec(self, name, path=None, target=None):
if name.split(".")[0] not in _SIBLINGS:
return None
log = os.environ.get({REFUSED_LOG_ENV!r})
if log:
with open(log, "a", encoding="utf-8") as stream:
stream.write(name + "\\n")
raise ModuleNotFoundError(
f"No module named {{name!r}} (refused: a lone openDox has no "
f"sibling)", name=name)


sys.meta_path.insert(0, _RefuseTheSiblings())
'''


def git(root: Path, *args: str) -> None:
"""`git` in `root` as #1144's fixture identity, with no inherited `GIT_*`
variable and no user or system configuration."""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update({
"GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "fixture",
"GIT_COMMITTER_EMAIL": "fixture@example.invalid",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull,
})
subprocess.run(["git", "-C", str(root), *args], check=True,
capture_output=True, env=env)


def fresh_repository(fixture: Path, parent: Path, *,
edits: dict[str, str] | None = None) -> Path:
"""#1144's preamble: `fixture` copied into a FRESH repository under
`parent`, named as the fixture is, with `edits` (a document's new text, by
name) applied before the one commit."""
root = parent / fixture.name
shutil.copytree(fixture, root)
for name, text in (edits or {}).items():
(root / name).write_text(text, encoding="utf-8")
git(root, "-c", "init.defaultBranch=main", "init", "-q")
git(root, "add", "-A")
git(root, "commit", "-qm", "fixture")
return root


class Child:
"""One `python -m <module> ...` child, with the siblings refused and its
standard output a buffered pipe. `workdir` holds the blocker and the log."""

def __init__(self, workdir: Path, module: str, *args: str) -> None:
blocker = workdir / "sibling-blocker"
blocker.mkdir(parents=True, exist_ok=True)
(blocker / "sitecustomize.py").write_text(_BLOCKER, encoding="utf-8")
self.refused_log = workdir / "refused-imports.log"
env = dict(os.environ)
env.pop("PYTHONUNBUFFERED", None)
env["PYTHONPATH"] = os.pathsep.join(
[str(blocker), *filter(None, [env.get("PYTHONPATH")])])
env[REFUSED_LOG_ENV] = str(self.refused_log)
self.argv = [sys.executable, "-m", module, *args]
verb = args[0] if args and not args[0].startswith("-") else ""
self.label = f"python -m {module} {verb}".strip()
self.process = subprocess.Popen(
self.argv, cwd=ROOT, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, encoding="utf-8",
errors="replace")
self.lines: queue.Queue[str | None] = queue.Queue()
self.stdout: list[str] = []
self.stderr: list[str] = []
self._pumps = [
threading.Thread(target=self._pump, args=(self.process.stdout, True),
daemon=True),
threading.Thread(target=self._pump, args=(self.process.stderr, False),
daemon=True),
]
for pump in self._pumps:
pump.start()

def _pump(self, stream, is_stdout: bool) -> None:
for line in stream:
(self.stdout if is_stdout else self.stderr).append(line)
if is_stdout:
self.lines.put(line)
if is_stdout:
self.lines.put(None)

def wait_for_line(self, pattern: re.Pattern[str]) -> re.Match[str]:
"""The first standard-output line matching `pattern`, read while the
child runs. Fails, naming what the child said, if it exits first or
the deadline passes."""
deadline = time.monotonic() + START_DEADLINE_SECONDS
while time.monotonic() < deadline:
try:
line = self.lines.get(timeout=0.25)
except queue.Empty:
continue
if line is None:
break
match = pattern.search(line)
if match:
return match
raise AssertionError(
f"{self.label} never printed a line matching "
f"{pattern.pattern!r} on its (buffered) standard output while it "
f"ran: exit status {self.process.poll()}, standard output "
f"{''.join(self.stdout)!r}, standard error {self.stderr_text()[-2000:]!r}")

def wait(self) -> int:
"""Run the child to completion and answer its exit status."""
try:
return self.process.wait(timeout=RUN_DEADLINE_SECONDS)
finally:
self.kill()
self._join()

def interrupt(self) -> int:
"""Send SIGINT, as Ctrl-C would, and answer the exit status.

A child that does not stop within `STOP_DEADLINE_SECONDS` is KILLED
before its pipes are joined, and the timeout is raised (Copilot at
openDox-code#66 1597511d, r4139607689). Its readers block until the
pipes close, so joining first would hold the caller for two more
deadlines on exactly the path, an interrupt that is ignored, this
helper exists to report."""
self.process.send_signal(signal.SIGINT)
try:
return self.process.wait(timeout=STOP_DEADLINE_SECONDS)
finally:
self.kill()
self._join()
Comment thread
brettheap marked this conversation as resolved.

def kill(self) -> None:
if self.process.poll() is None:
self.process.kill()
self.process.wait(timeout=STOP_DEADLINE_SECONDS)

def _join(self) -> None:
for pump in self._pumps:
pump.join(timeout=STOP_DEADLINE_SECONDS)

def stdout_text(self) -> str:
return "".join(self.stdout)

def stderr_text(self) -> str:
return "".join(self.stderr)

def refused(self) -> list[str]:
"""Every sibling name the child tried to import, in order."""
if not self.refused_log.exists():
return []
return self.refused_log.read_text(encoding="utf-8").split()


def run_module(workdir: Path, module: str, *args: str) -> tuple[Child, int]:
"""A child run to completion: `(child, exit status)`."""
child = Child(workdir, module, *args)
return child, child.wait()
Loading
Loading