-
Notifications
You must be signed in to change notification settings - Fork 0
T056, the standalone generate path, end to end (5.1 part) (plan 034) #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
brettheap
merged 61 commits into
main
from
build/034-p2r-t056-standalone-generate-end-to-end
Sep 30, 2026
Merged
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 834f8ea
T052: 5.4, declare the generator seam, and register openDox's own gen…
brettheap 4ca45d2
T052: each declared input optional; a default's generation recorded o…
brettheap 09560ee
Merge remote-tracking branch 'origin/main' into feat/plain-documents-…
brettheap f52b111
T051: the malformed fixture, one title-and-summary-are-text violation…
brettheap ac553e4
Merge main (T036 #52, T037 #55 -> 2d116415) into T054's branch
brettheap 5538086
Merge T050's fixture (openDox-code#53, feat/plain-documents-fixture 0…
brettheap b1946db
Merge T051's fixture (openDox-code#56, feat/malformed-fixture f52b111…
brettheap 25fe752
T054 (work in progress): openDox's small neutral projection over Corp…
brettheap 0f42f67
T054: the projection's tests, the holder's two rulings, and the displ…
brettheap b2f9222
T054: compare the commit date as a time, as git spells it either way …
brettheap c0747e9
T054: an empty topics header is a declaration; T050's docstring says …
brettheap 5a6fe63
T054: a word is a run of letters beside digits too; a pin labels the …
brettheap 21dfdb7
Merge main (T036 #52, T037 #55 -> 2d116415) into T052's branch
brettheap bce09c5
T052: each registration keeps its own records at the generator seam
brettheap 97b5b01
T052: leave point 6 of the seam test's docstring as T054 edits it
brettheap 8d507c5
T052: current() reads the registration once
brettheap e8a733d
Merge T052's head (openDox-code#54, build/034-p2g-t052-generator-seam…
brettheap c27eac3
T055: serve and generate standalone (5.5, 4.3 part) (plan 034)
brettheap ea49c42
T055: the canonical path meets the hidden-name rule; a token variable…
brettheap bfb9c47
T055: serve.main hands each data-source option over as given; droppin…
brettheap 244d7b7
T055: the unavailable-validator warning says which of its two cases h…
brettheap 19c2678
T055: the writer refuses what JSON cannot carry; one request serves t…
brettheap 8c09d74
T055: the source arm resolves its entry once, and asks for the path b…
brettheap f4ef63d
T055: the default writer replaces the snapshot atomically
brettheap 8defc37
Merge main 2d116415 (T037, #55) into T050's branch
brettheap 521de95
T050: the docstring says the required check collects this suite
brettheap 7028f61
T050: the unrelated source shares no topic with the pair, not just no…
brettheap 4ba410f
T050: compare each source's whole derived topic set; pin the fixture'…
brettheap 44dcc3a
Merge main 2d116415 (T037, #55) into T051's branch
brettheap fd55f9f
Merge T052's head (openDox-code#54, build/034-p2g-t052-generator-seam…
brettheap ba6adf5
Merge T050's head (openDox-code#53, feat/plain-documents-fixture 4ba4…
brettheap 07d2b1b
Merge T051's head (openDox-code#56, feat/malformed-fixture 44dcc3a1) …
brettheap d97a4aa
Merge main dc3765dd (T050 landed, #53) into T054's branch
brettheap 2f515c5
Merge main dc3765dd (T050 landed, #53) into T052's branch
brettheap 32bd4fa
Merge T052's head (openDox-code#54 2f515c57) into T054's branch
brettheap 8e7da4a
T054: openDox's own scaffold carries the small neutral field set
brettheap 8cbb1ea
Merge T054's head (openDox-code#57, build/034-p2p-t054-neutral-projec…
brettheap d7aa9d8
T055: an empty --project-register or --possibles is refused, fail closed
brettheap 687d37b
T055: a refresh keeps the snapshot's permissions
brettheap 96f18c4
T055: the source arm's two lookups are made under the registry's lock
brettheap e3ef506
T055: the arm confines its entry in hand, reads take the lock, the re…
brettheap 1a60367
Merge main fa8862cc (T051 and T052 landed, #56 and #54) into T054's b…
brettheap 03e06cc
T054: a scaffold's leading title and summary are one header line each
brettheap 65c943f
T056: the standalone generate path, end to end, and the served URL is…
brettheap 1597511
T056: the lone-openDox child process becomes a test helper, for T058'…
brettheap 50b0d42
T054: one emptiness rule in both vocabularies; the commit date uses t…
brettheap d7954fc
T054: the projection reads keys as paths only where the adapter decla…
brettheap 42a08a3
T056: an interrupted child that will not stop is killed before its pi…
brettheap 92a9167
Merge T054's final head (openDox-code#57, d7954fc6) into T055's branch
brettheap 814516b
Merge main a691e4e4 (T054 landed, #57) into T055's branch
brettheap 5a26532
Merge T055's head (openDox-code#59 814516b7) into T056's branch
brettheap e939c31
T056: the undeclared stage: value is looked for inside every string, …
brettheap c2a8ad9
Merge main 8ec08e91 (T057 landed, #58) into T055's branch
brettheap e357477
Merge T055's head (openDox-code#59 c2a8ad9f) into T056's branch
brettheap 183b40f
T055: a regenerate passes the project register only when one is set
brettheap caa01ca
T056: the "serving until interrupted" line is read while the server runs
brettheap 38761c7
Merge T055's head (openDox-code#59 183b40fc) into T056's branch
brettheap a6e953c
Merge main (fa140875, T055 landed as openDox-code#59) into T056's branch
brettheap 149d729
T056: the harness gives the child Ctrl-C as at a terminal, whatever t…
brettheap a7bda06
T056: the stage notice's six keys are checked as one rendered list
brettheap File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
|
||
| 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() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.