feat(deploy): two-mode TUI canary (candidate scrub vs current), negative deployment regression, release-recursion invariant
CI Pipeline / best-practices (push) Successful in 8s
CI Pipeline / documentation (push) Successful in 4s
CI Pipeline / canary (push) Failing after 37s
CI Pipeline / lint (push) Successful in 45s
Auto-version and Release / release (push) Successful in 55s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / unit-tests (push) Failing after 59s
deploy / deploy (push) Failing after 15m24s
CI Pipeline / required-checks (push) Successful in 1s
CI Pipeline / best-practices (push) Successful in 8s
CI Pipeline / documentation (push) Successful in 4s
CI Pipeline / canary (push) Failing after 37s
CI Pipeline / lint (push) Successful in 45s
Auto-version and Release / release (push) Successful in 55s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / unit-tests (push) Failing after 59s
deploy / deploy (push) Failing after 15m24s
CI Pipeline / required-checks (push) Successful in 1s
Handoff 2026-09-11 Part A-C:
A. tui_startup_canary two modes: candidate (--stage, sys.path SCRUBBED of
/mnt/workdrive/rune-code/current + imported-rune purge + contamination
check that rune resolves from the stage) and current (consuming path).
Scrub proven: candidate mode on the 76f71fc stage reports 76f71fc/0.66.2
while current is bb536c9/0.66.3. FIXED a live exit-code bug: main()
returned 1 but was called without sys.exit — a failed canary exited 0
(the deploy gate's own returncode check in deploy_stage.py was correct,
so promotion was never actually bypassed; the standalone script was).
B. Negative deployment canary (tests/test_deployment_canary.py): broken
staged candidate → canary exit 1 naming the failure → current identity
byte-identical before/after. Plus current-mode identity regression.
C. Release recursion: is_release_commit() extracted into
version_convergence.py (ONE source of truth); workflow guard uses
--is-release-subject CLI (no inline python in YAML); regression tests
cover recognition, never-bump-from-release-commits (incl. the 5x loop
case), substantive commits still bumping, and the CLI exit contract.
1241 tests green; ratchet 353/80; henxels clean.
This commit is contained in:
1 parent
bb536c9f02
commit
c2528c1d9d
6 files changed
+315
-39
No files matched your search
@@ -62,12 +62,15 @@ jobs:
|
||||
# commits since the last tag. chore(release) commits never
|
||||
# warrant a bump → the bump commit cannot recurse.
|
||||
if [ -z "$PR_TITLE" ] || [ "$PR_TITLE" = "null" ]; then
|
||||
# Recursive-loop guard (handoff §3): HEAD being a bump commit
|
||||
# means THIS push already applied the bump — do not re-bump.
|
||||
# Recursive-loop guard: HEAD being a bump commit means THIS
|
||||
# push already applied the bump — do not re-bump. The decision
|
||||
# lives in version_convergence (--is-release-subject), one
|
||||
# source of truth shared with the regression test.
|
||||
HEAD_SUBJECT=$(git log -1 --format=%s)
|
||||
case "$HEAD_SUBJECT" in
|
||||
chore\(release\)*) echo "HEAD is a release bump — loop guard, no re-bump"; exit 0;;
|
||||
esac
|
||||
if python3 scripts/version_convergence.py --is-release-subject "$HEAD_SUBJECT"; then
|
||||
echo "HEAD is a release bump — loop guard, no re-bump"
|
||||
exit 0
|
||||
fi
|
||||
WARRANTED=$(python3 scripts/version_convergence.py | python3 -c "import json,sys; print(json.load(sys.stdin).get('warranted_bump') or '')")
|
||||
echo "push convergence: warranted_bump=$WARRANTED"
|
||||
if [ -z "$WARRANTED" ]; then
|
||||
|
||||
+24
-15
@@ -10,6 +10,7 @@ the previous deployment remains available).
|
||||
Exit 0 = deployed and verified. Non-zero = deployment refused; the previous
|
||||
deployment stays current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -32,7 +33,8 @@ def main() -> int:
|
||||
repo = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
|
||||
sha = subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if not sha:
|
||||
print(f"FATAL: cannot resolve HEAD in {repo}", file=sys.stderr)
|
||||
@@ -62,15 +64,15 @@ def main() -> int:
|
||||
"version": rune_dev.__version__,
|
||||
"deployed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
(tmp_stage / "src" / "rune" / "_build_identity.json").write_text(
|
||||
json.dumps(identity, indent=2))
|
||||
(tmp_stage / "src" / "rune" / "_build_identity.json").write_text(json.dumps(identity, indent=2))
|
||||
|
||||
# --- verify the STAGED tree (before promotion) ----------------------
|
||||
env = dict(os.environ,
|
||||
RUNE_HOME="/mnt/workdrive/rune",
|
||||
RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
|
||||
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
|
||||
verify = subprocess.run(
|
||||
[VENV_PY, "-c", f"""
|
||||
[
|
||||
VENV_PY,
|
||||
"-c",
|
||||
f"""
|
||||
import json, sys
|
||||
sys.path.insert(0, {str(tmp_stage / "src")!r})
|
||||
import rune
|
||||
@@ -80,12 +82,16 @@ assert info["commit"] == {sha!r}, f"SHA mismatch: {{info['commit']}}"
|
||||
assert info["version"], "empty version"
|
||||
from rune import adaptive_router, builder_bridge, context_broker, rune_operator # noqa: F401
|
||||
print("VERIFIED:", json.dumps(info))
|
||||
"""],
|
||||
capture_output=True, text=True, env=env, timeout=120)
|
||||
""",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
print(verify.stdout.strip())
|
||||
if verify.returncode != 0:
|
||||
print("VERIFY FAILED — deployment refused:\n" + verify.stderr[-800:],
|
||||
file=sys.stderr)
|
||||
print("VERIFY FAILED — deployment refused:\n" + verify.stderr[-800:], file=sys.stderr)
|
||||
shutil.rmtree(tmp_stage, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
@@ -94,12 +100,15 @@ print("VERIFIED:", json.dumps(info))
|
||||
# revision whose TUI cannot initialize never becomes current.
|
||||
canary = subprocess.run(
|
||||
[VENV_PY, str(REPO_ROOT / "scripts" / "tui_startup_canary.py")],
|
||||
capture_output=True, text=True, env={**env, "PYTHONPATH": str(tmp_stage / "src")},
|
||||
timeout=180, cwd=str(tmp_stage))
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**env, "PYTHONPATH": str(tmp_stage / "src")},
|
||||
timeout=180,
|
||||
cwd=str(tmp_stage),
|
||||
)
|
||||
print(canary.stdout.strip())
|
||||
if canary.returncode != 0:
|
||||
print("TUI STARTUP CANARY FAILED — deployment refused:\n"
|
||||
+ canary.stderr[-800:], file=sys.stderr)
|
||||
print("TUI STARTUP CANARY FAILED — deployment refused:\n" + canary.stderr[-800:], file=sys.stderr)
|
||||
shutil.rmtree(tmp_stage, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tui_startup_canary.py — REAL TUI initialization canary (handoff §4).
|
||||
"""tui_startup_canary.py — REAL TUI initialization canary, two proof modes.
|
||||
|
||||
Runs the ACTUAL RuneApp initialization through Textual's own test driver —
|
||||
the same app construction + on_mount wiring the interactive TUI executes —
|
||||
then exits 0. No fake smoke path: RuneApp is constructed, mounted, and its
|
||||
on_mount handlers run; the interactive loop is simply never entered.
|
||||
Mode CANDIDATE (pre-promotion, default when --stage is given):
|
||||
Tests the STAGED candidate exactly as supplied. Nothing from
|
||||
/mnt/workdrive/rune-code/current is prepended or preferred — sys.path is
|
||||
scrubbed of any current-deployment entry so a broken candidate cannot be
|
||||
masked by the healthy previous deployment. If the staged candidate is
|
||||
broken while current is healthy, this canary MUST fail.
|
||||
|
||||
Prints the build identity on success. Exit non-zero on any initialization
|
||||
failure (a staged revision whose TUI cannot initialize must NOT promote).
|
||||
Mode CURRENT (post-promotion consuming-path proof, --mode current):
|
||||
Tests /mnt/workdrive/rune-code/current/src — proving the promoted
|
||||
artifact is what the real consuming path resolves to.
|
||||
|
||||
Both modes run the ACTUAL RuneApp initialization through Textual's own
|
||||
test driver — the same app construction + on_mount wiring the interactive
|
||||
TUI executes — then exit 0. The interactive loop is never entered.
|
||||
|
||||
Prints the build identity (mode + identity) on success. Exit non-zero on
|
||||
any initialization failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Self-bootstrap: when the CURRENT deployment exists, run against it (the
|
||||
# consuming path), so this canary always proves what the launcher proves.
|
||||
_CURRENT = "/mnt/workdrive/rune-code/current/src"
|
||||
if os.path.isdir(_CURRENT) and _CURRENT not in sys.path:
|
||||
sys.path.insert(0, _CURRENT)
|
||||
CURRENT_SRC = "/mnt/workdrive/rune-code/current/src"
|
||||
|
||||
|
||||
def _scrub_current_from_path() -> None:
|
||||
"""Remove every current-deployment entry from sys.path (candidate mode).
|
||||
|
||||
The runner's checkout workspace or ambient PYTHONPATH may contain the
|
||||
current deployment; a candidate canary must not silently resolve rune
|
||||
from it.
|
||||
"""
|
||||
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.abspath(CURRENT_SRC)]
|
||||
# Also drop any already-imported rune modules from a prior import.
|
||||
for mod in [m for m in sys.modules if m == "rune" or m.startswith("rune.")]:
|
||||
del sys.modules[mod]
|
||||
|
||||
|
||||
async def _canary() -> dict:
|
||||
@@ -36,19 +58,59 @@ async def _canary() -> dict:
|
||||
assert app.status_bar is not None, "status bar not initialized"
|
||||
# One interaction cycle: let pending messages/refreshes settle.
|
||||
await pilot.pause(0.1)
|
||||
from rune import build_info
|
||||
import rune
|
||||
|
||||
return build_info()
|
||||
return rune.build_info()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Rune TUI startup canary")
|
||||
ap.add_argument(
|
||||
"--mode",
|
||||
choices=("candidate", "current"),
|
||||
default="candidate",
|
||||
help="candidate: test the staged tree (sys.path scrubbed of current); current: test the consuming path",
|
||||
)
|
||||
ap.add_argument("--stage", help="staged candidate root (candidate mode)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.mode == "candidate":
|
||||
if not args.stage:
|
||||
print("candidate mode requires --stage", file=sys.stderr)
|
||||
return 2
|
||||
stage_root = Path(args.stage).resolve()
|
||||
stage_src = str(stage_root / "src")
|
||||
if not (stage_root / "src" / "rune").is_dir():
|
||||
print(f"staged candidate has no src/rune: {stage_src}", file=sys.stderr)
|
||||
return 2
|
||||
_scrub_current_from_path()
|
||||
sys.path.insert(0, stage_src)
|
||||
# Verify the scrub worked: rune must resolve from the STAGE, not current.
|
||||
import rune # noqa: E402
|
||||
|
||||
resolved = getattr(rune, "__file__", "")
|
||||
if "rune-code/current" in resolved:
|
||||
print(
|
||||
f"CANARY CONTAMINATED: rune imported from current, not the staged candidate ({resolved})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
else:
|
||||
if CURRENT_SRC not in sys.path:
|
||||
sys.path.insert(0, CURRENT_SRC)
|
||||
|
||||
async def _run() -> dict:
|
||||
try:
|
||||
return await _canary()
|
||||
finally:
|
||||
pass
|
||||
|
||||
try:
|
||||
info = asyncio.run(_canary())
|
||||
except Exception as exc:
|
||||
print(f"TUI STARTUP CANARY FAILED: {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr)
|
||||
info = asyncio.run(_run())
|
||||
except Exception as exc: # noqa: BLE001 — the canary's JOB is to catch any startup failure and report it as a failed gate; narrowing here would let an unexpected exception class crash the runner instead of failing the gate cleanly.
|
||||
print(f"TUI STARTUP CANARY FAILED ({args.mode}): {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print("TUI STARTUP VERIFIED:", json.dumps(info))
|
||||
print(json.dumps({"mode": args.mode, **info}))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +114,18 @@ def highest_warranted_bump(subjects: list[str]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_release_commit(subject: str) -> bool:
|
||||
"""True when the commit is a release/version bump commit.
|
||||
|
||||
ONE SOURCE OF TRUTH consumed by auto-version.yml (push convergence
|
||||
guard) and by the recursion regression test: a release commit must
|
||||
NEVER trigger another version bump (recursive-loop defect observed
|
||||
2026-09-11: five 0.66.2 bumps before the guard landed).
|
||||
"""
|
||||
s = subject.strip().lower()
|
||||
return s.startswith("chore(release)") or s.startswith("chore: release")
|
||||
|
||||
|
||||
def next_version(version: str, kind: str) -> str:
|
||||
"""Apply a bump kind to a version string (0.x convention: breaking -> minor)."""
|
||||
major, minor, patch = version.split(".")
|
||||
@@ -189,8 +201,18 @@ def decision_in_repo(repo: Path | None = None) -> dict[str, Any]:
|
||||
|
||||
def main(argv: list[str] | None = None) -> dict[str, Any]:
|
||||
parser = argparse.ArgumentParser(description="Release convergence decision (JSON on stdout)")
|
||||
parser.add_argument(
|
||||
"--is-release-subject",
|
||||
dest="is_release_subject",
|
||||
metavar="SUBJECT",
|
||||
help="exit 0 when SUBJECT is a release bump commit, exit 1 otherwise (loop guard); no JSON",
|
||||
)
|
||||
parser.add_argument("--repo", type=Path, default=None, help="repo root (default: cwd)")
|
||||
args = parser.parse_args(argv)
|
||||
if args.is_release_subject is not None:
|
||||
import sys as _sys
|
||||
|
||||
_sys.exit(0 if is_release_commit(args.is_release_subject) else 1)
|
||||
decision = decision_in_repo(args.repo)
|
||||
print(json.dumps(decision))
|
||||
return decision
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Negative deployment proof (handoff 2026-09-11 Part B): a broken staged
|
||||
candidate MUST fail the pre-promotion canary and MUST NOT touch current.
|
||||
|
||||
Skipped when the local deployment layout (/mnt/workdrive/rune-code) is not
|
||||
present (e.g. CI runners) — the property it guards is deployment-local."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
CANARY = REPO / "scripts" / "tui_startup_canary.py"
|
||||
VENV_PY = "/home/nick/.venvs/memory/bin/python3"
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
DEPLOY_ROOT.exists() and (DEPLOY_ROOT / "current").is_symlink() and Path(VENV_PY).exists(),
|
||||
"local deployment layout not present",
|
||||
)
|
||||
class NegativeDeploymentCanary(unittest.TestCase):
|
||||
def test_broken_candidate_fails_and_current_untouched(self):
|
||||
# Pick the newest known-good stage as the base for the broken fixture.
|
||||
stages = sorted(
|
||||
(
|
||||
p
|
||||
for p in DEPLOY_ROOT.iterdir()
|
||||
if p.is_dir() and (p / "src" / "rune").is_dir() and p.name not in ("current", "last-good")
|
||||
),
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
self.assertTrue(stages, "no known-good stage to copy")
|
||||
base = stages[-1]
|
||||
|
||||
stage = DEPLOY_ROOT / "negative-test-stage"
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
shutil.copytree(base, stage)
|
||||
tui = stage / "src" / "rune" / "rune_tui.py"
|
||||
tui.write_text(tui.read_text() + "\nraise RuntimeError('deliberately broken candidate')\n")
|
||||
|
||||
identity_file = DEPLOY_ROOT / "current" / "src" / "rune" / "_build_identity.json"
|
||||
before = json.loads(identity_file.read_text())
|
||||
|
||||
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable if Path(sys.executable).exists() else VENV_PY,
|
||||
str(CANARY),
|
||||
"--mode",
|
||||
"candidate",
|
||||
"--stage",
|
||||
str(stage),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
)
|
||||
self.assertNotEqual(r.returncode, 0, "broken candidate must fail")
|
||||
self.assertIn("deliberately broken", r.stderr)
|
||||
|
||||
after = json.loads(identity_file.read_text())
|
||||
self.assertEqual(before, after, "current untouched by failed candidate")
|
||||
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
|
||||
def test_current_mode_reports_deployed_identity(self):
|
||||
identity_file = DEPLOY_ROOT / "current" / "src" / "rune" / "_build_identity.json"
|
||||
expected = json.loads(identity_file.read_text())
|
||||
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
|
||||
r = subprocess.run(
|
||||
[sys.executable if Path(sys.executable).exists() else VENV_PY, str(CANARY), "--mode", "current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-300:])
|
||||
report = json.loads(r.stdout.strip().splitlines()[-1])
|
||||
self.assertEqual(report["mode"], "current")
|
||||
self.assertEqual(report["commit"], expected["commit"])
|
||||
self.assertEqual(report["version"], expected["version"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Release recursion regression (handoff 2026-09-11 Part C).
|
||||
|
||||
The rule: a release bump commit MUST NOT trigger another version bump.
|
||||
The decision lives in version_convergence (ONE source of truth) — the
|
||||
workflow guard calls --is-release-subject; these tests exercise the same
|
||||
function the workflow uses, plus the CLI exit-code contract itself.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
from version_convergence import is_release_commit # noqa: E402
|
||||
|
||||
|
||||
class ReleaseRecursionRule(unittest.TestCase):
|
||||
def test_release_commit_is_recognized(self):
|
||||
# The EXACT subject shape the auto-version workflow generates.
|
||||
self.assertTrue(
|
||||
is_release_commit(
|
||||
"chore(release): auto-bump version to 0.66.2 — earned by # "
|
||||
"(patch: converged release (push-to-master bump))"
|
||||
)
|
||||
)
|
||||
self.assertTrue(is_release_commit("chore(release): auto-bump version to 0.61.0"))
|
||||
self.assertTrue(is_release_commit("chore: release v0.60.0"))
|
||||
|
||||
def test_release_commit_never_warrants_a_bump(self):
|
||||
# The deeper invariant: classify_subject('other') does not feed
|
||||
# highest_warranted_bump, so a release commit alone cannot bump.
|
||||
from version_convergence import classify_subject, highest_warranted_bump
|
||||
|
||||
subject = (
|
||||
"chore(release): auto-bump version to 0.66.2 — earned by # (patch: converged release (push-to-master bump))"
|
||||
)
|
||||
self.assertEqual(classify_subject(subject), "other")
|
||||
self.assertIsNone(highest_warranted_bump([subject]))
|
||||
self.assertIsNone(highest_warranted_bump([subject] * 5)) # the loop case
|
||||
|
||||
def test_substantive_commits_still_bump(self):
|
||||
from version_convergence import classify_subject, highest_warranted_bump
|
||||
|
||||
self.assertEqual(classify_subject("feat(ADAPT-LIVE): route discriminator"), "feat")
|
||||
self.assertEqual(classify_subject("fix(deploy): prefix-match"), "fix")
|
||||
self.assertEqual(
|
||||
highest_warranted_bump(["feat(x): a", "fix(y): b"]),
|
||||
"minor",
|
||||
)
|
||||
|
||||
def test_cli_exit_contract(self):
|
||||
# The workflow consumes the CLI, not the Python API — prove the
|
||||
# exit-code contract the guard depends on.
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(REPO / "scripts" / "version_convergence.py"),
|
||||
"--is-release-subject",
|
||||
"chore(release): auto-bump version to 0.66.2",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(REPO),
|
||||
)
|
||||
self.assertEqual(r.returncode, 0, "release subject → guard exits 0 (skip)")
|
||||
r2 = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(REPO / "scripts" / "version_convergence.py"),
|
||||
"--is-release-subject",
|
||||
"feat(x): real feature",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(REPO),
|
||||
)
|
||||
self.assertEqual(r2.returncode, 1, "feat subject → guard exits 1 (bump)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user