Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,350 @@
|
||||
r"""
|
||||
core/run_lock.py — the record of the latest bot run, Logs\bot_run.json
|
||||
(spec 2026-10-14-start-bot-from-web-page-design.md).
|
||||
|
||||
python tests\test_run_lock.py
|
||||
|
||||
Every case runs with cfg.LOGS pointed at a temp folder.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
from ecw_bot.core import run_lock # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def _dead_pid() -> int:
|
||||
p = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
p.wait()
|
||||
return p.pid
|
||||
|
||||
|
||||
@case
|
||||
def pid_alive_is_true_for_this_python_process():
|
||||
assert run_lock.pid_alive(os.getpid()) is True
|
||||
|
||||
|
||||
@case
|
||||
def pid_alive_is_false_for_junk_and_ended_processes():
|
||||
for junk in (None, "", "x", 0, -5):
|
||||
assert run_lock.pid_alive(junk) is False, junk
|
||||
assert run_lock.pid_alive(_dead_pid()) is False
|
||||
|
||||
|
||||
@case
|
||||
def pid_alive_is_false_for_a_live_process_that_is_not_python():
|
||||
"""A stale record's pid reused by another program must not read as a
|
||||
live bot run."""
|
||||
p = subprocess.Popen(["ping", "-n", "6", "127.0.0.1"],
|
||||
stdout=subprocess.DEVNULL)
|
||||
try:
|
||||
assert run_lock.pid_alive(p.pid) is False
|
||||
finally:
|
||||
p.kill()
|
||||
p.wait()
|
||||
|
||||
|
||||
@case
|
||||
def process_created_reads_the_creation_time():
|
||||
made = run_lock.process_created(os.getpid())
|
||||
assert made is not None and made <= datetime.now(), made
|
||||
assert datetime.now() - made < timedelta(days=1), made
|
||||
assert run_lock.process_created(_dead_pid()) is None
|
||||
assert run_lock.process_created(None) is None
|
||||
|
||||
|
||||
@case
|
||||
def process_created_is_none_for_an_ended_process_still_held_open():
|
||||
"""The Popen object still holds its handle after wait(): the ended
|
||||
process can be opened and has times, but it is not running."""
|
||||
p = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
p.wait()
|
||||
assert run_lock.process_created(p.pid) is None
|
||||
del p
|
||||
|
||||
|
||||
@case
|
||||
def an_offset_aware_start_time_keeps_the_pid_verdict():
|
||||
"""A record whose started_at carries a UTC offset must not crash
|
||||
is_live() (aware vs naive) — in the page or in the bot's acquire()."""
|
||||
rec = {"pid": os.getpid(), "started_at": "2026-10-14T10:00:00+05:30",
|
||||
"ended_at": None}
|
||||
assert run_lock.is_live(rec) is True
|
||||
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
|
||||
run_lock.path().write_text(json.dumps(rec), encoding="utf-8")
|
||||
try:
|
||||
run_lock.acquire("b.log")
|
||||
except run_lock.Busy:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("acquire went over a live record")
|
||||
|
||||
|
||||
@case
|
||||
def a_reused_pid_does_not_read_as_live():
|
||||
"""The record's pid is this (live python) process, but the record was
|
||||
started an hour before this process existed: the pid was reused."""
|
||||
old = run_lock.process_created(os.getpid()) - timedelta(hours=1)
|
||||
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
|
||||
run_lock.path().write_text(json.dumps(
|
||||
{"pid": os.getpid(),
|
||||
"started_at": old.isoformat(timespec="seconds"),
|
||||
"log_path": "old.log", "ended_at": None}), encoding="utf-8")
|
||||
assert run_lock.is_live(run_lock.read()) is False
|
||||
rec = run_lock.acquire("new.log")
|
||||
assert rec["log_path"] == "new.log", rec
|
||||
|
||||
|
||||
@case
|
||||
def an_unreadable_start_time_keeps_the_pid_verdict():
|
||||
rec = {"pid": os.getpid(), "started_at": "garbage", "ended_at": None}
|
||||
assert run_lock.is_live(rec) is True
|
||||
assert run_lock.is_live({"pid": os.getpid(), "ended_at": None}) is True
|
||||
|
||||
|
||||
@case
|
||||
def acquire_writes_this_process_record():
|
||||
rec = run_lock.acquire(pathlib.Path("Logs/run_x.log"), source="web")
|
||||
assert rec["pid"] == os.getpid() and rec["source"] == "web", rec
|
||||
assert rec["ended_at"] is None and rec["exit_code"] is None, rec
|
||||
on_disk = json.loads(run_lock.path().read_text(encoding="utf-8"))
|
||||
assert on_disk == rec, on_disk
|
||||
|
||||
|
||||
@case
|
||||
def acquire_source_defaults_to_the_env_then_terminal():
|
||||
os.environ.pop("ECW_RUN_SOURCE", None)
|
||||
assert run_lock.acquire("a.log")["source"] == "terminal"
|
||||
run_lock.finish(0)
|
||||
os.environ["ECW_RUN_SOURCE"] = "web"
|
||||
try:
|
||||
assert run_lock.acquire("a.log")["source"] == "web"
|
||||
finally:
|
||||
os.environ.pop("ECW_RUN_SOURCE", None)
|
||||
|
||||
|
||||
@case
|
||||
def a_live_record_refuses_a_second_run():
|
||||
run_lock.acquire("a.log")
|
||||
try:
|
||||
run_lock.acquire("b.log")
|
||||
except run_lock.Busy as exc:
|
||||
assert "already going" in str(exc), exc
|
||||
else:
|
||||
raise AssertionError("second acquire was allowed")
|
||||
|
||||
|
||||
@case
|
||||
def a_stale_record_of_a_dead_pid_is_replaced():
|
||||
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
|
||||
run_lock.path().write_text(json.dumps(
|
||||
{"pid": _dead_pid(), "started_at": "2026-10-14T09:00:00",
|
||||
"log_path": "old.log", "ended_at": None}), encoding="utf-8")
|
||||
rec = run_lock.acquire("new.log")
|
||||
assert rec["log_path"] == "new.log", rec
|
||||
|
||||
|
||||
@case
|
||||
def an_ended_record_does_not_block():
|
||||
run_lock.acquire("a.log")
|
||||
run_lock.finish(0)
|
||||
assert run_lock.acquire("b.log")["log_path"] == "b.log"
|
||||
|
||||
|
||||
@case
|
||||
def finish_records_the_exit_code_and_end_time():
|
||||
run_lock.acquire("a.log")
|
||||
run_lock.finish(5)
|
||||
rec = run_lock.read()
|
||||
assert rec["exit_code"] == 5 and rec["ended_at"], rec
|
||||
assert run_lock.is_live(rec) is False
|
||||
|
||||
|
||||
@case
|
||||
def finish_leaves_another_process_record_alone():
|
||||
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
|
||||
other = {"pid": os.getpid() + 100000, "ended_at": None,
|
||||
"exit_code": None}
|
||||
run_lock.path().write_text(json.dumps(other), encoding="utf-8")
|
||||
run_lock.finish(0)
|
||||
assert run_lock.read() == other
|
||||
|
||||
|
||||
@case
|
||||
def finish_never_raises_without_a_record():
|
||||
run_lock.finish(0) # no file at all
|
||||
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
|
||||
run_lock.path().write_text("{not json", encoding="utf-8")
|
||||
run_lock.finish(0)
|
||||
assert run_lock.read() is None
|
||||
|
||||
|
||||
@case
|
||||
def mark_stopped_ends_the_named_run():
|
||||
rec = run_lock.acquire("a.log")
|
||||
run_lock.mark_stopped(rec["pid"])
|
||||
rec = run_lock.read()
|
||||
assert rec["stopped_by_operator"] is True and rec["ended_at"], rec
|
||||
assert rec["exit_code"] is None, rec
|
||||
|
||||
|
||||
# ── the flow: main() and the [RESULT] line ───────────────────────────────────
|
||||
|
||||
import io # noqa: E402
|
||||
import contextlib # noqa: E402
|
||||
|
||||
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
||||
|
||||
|
||||
def _main_with(inner):
|
||||
"""flow.main() with runlog stubbed and `_main` replaced by `inner`."""
|
||||
orig = (flow.runlog.start, flow._main)
|
||||
# In a temp folder: main() writes the Run's events beside the log, and
|
||||
# a test must not leave one in the real Logs\.
|
||||
test_log = pathlib.Path(tempfile.mkdtemp()) / "run_test.log"
|
||||
flow.runlog.start = lambda: test_log
|
||||
flow._main = inner
|
||||
out = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out):
|
||||
return flow.main(), out.getvalue()
|
||||
finally:
|
||||
flow.runlog.start, flow._main = orig
|
||||
|
||||
|
||||
@case
|
||||
def main_records_the_run_and_its_exit_code():
|
||||
seen = {}
|
||||
|
||||
def inner(log_path):
|
||||
seen["live"] = run_lock.is_live(run_lock.read())
|
||||
seen["log"] = str(log_path)
|
||||
return 5
|
||||
code, _ = _main_with(inner)
|
||||
assert code == 5
|
||||
assert seen["live"] is True and seen["log"].endswith("run_test.log"), seen
|
||||
rec = run_lock.read()
|
||||
assert rec["exit_code"] == 5 and rec["ended_at"], rec
|
||||
|
||||
|
||||
@case
|
||||
def main_finishes_the_record_when_the_run_raises():
|
||||
def inner(log_path):
|
||||
raise RuntimeError("boom")
|
||||
try:
|
||||
_main_with(inner)
|
||||
except RuntimeError:
|
||||
pass
|
||||
rec = run_lock.read()
|
||||
assert rec["ended_at"] and rec["exit_code"] == 1, rec
|
||||
|
||||
|
||||
@case
|
||||
def main_refuses_a_second_run_before_login():
|
||||
run_lock.acquire("other.log") # this process: live python
|
||||
called = []
|
||||
code, out = _main_with(lambda log_path: called.append(1) or 0)
|
||||
assert code == 2 and called == [], (code, out)
|
||||
assert "[RUN] A bot run is already going" in out, out
|
||||
|
||||
|
||||
@case
|
||||
def main_stops_cleanly_when_the_record_cannot_be_written():
|
||||
"""acquire() failing for a reason other than Busy (disk full, a locked
|
||||
file): a [RUN] line and exit 2, not a traceback."""
|
||||
def broken(log_path, source=None):
|
||||
raise PermissionError(13, "Access is denied\nsecond line")
|
||||
orig = flow.run_lock.acquire
|
||||
flow.run_lock.acquire = broken
|
||||
called = []
|
||||
try:
|
||||
code, out = _main_with(lambda log_path: called.append(1) or 0)
|
||||
finally:
|
||||
flow.run_lock.acquire = orig
|
||||
assert code == 2 and called == [], (code, out)
|
||||
assert ("[RUN] Could not write the run record ([Errno 13] Access is "
|
||||
"denied) — not starting") in out, out
|
||||
|
||||
|
||||
@case
|
||||
def each_claim_prints_its_result_line():
|
||||
orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||
flow.claim_results.search_claim, flow._read_claim_form,
|
||||
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
|
||||
flow._save_results_db, flow.claim_form.clear_screen)
|
||||
cfg.SHOW_EACH_CLAIM = False
|
||||
cfg.SHOW_CLAIM_IDS = False
|
||||
cfg.CLAIM_PROCESS_LIMIT = 0
|
||||
|
||||
def search(page, cid):
|
||||
return {"id": cid, "count": 1, "found": cid != "3", "seconds": 1.0}
|
||||
flow.claim_results.search_claim = search
|
||||
flow._read_claim_form = lambda page, cid, head: {
|
||||
"claim_no": cid,
|
||||
"cpt": [{"code": "99213", "checked": True, "fee": "100.00"}],
|
||||
"icd": [{"code": "Z00.00"}],
|
||||
"progress_notes": {"opened": "modal", "text": "Office visit."},
|
||||
"_conditions": {"fired": [], "saved": False, "failed": [],
|
||||
"review": [], "comments": {}}}
|
||||
flow.claim_filters.clear_claim_no = lambda page: None
|
||||
flow._write_charge_summary = lambda info, o: None
|
||||
flow._save_results_db = lambda info, o, d: None
|
||||
flow.claim_form.clear_screen = lambda page, cid, keep=(): []
|
||||
out = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out):
|
||||
flow._process_claims(None, {"ids": ["1", "3"], "file": None})
|
||||
finally:
|
||||
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||
flow.claim_results.search_claim, flow._read_claim_form,
|
||||
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
|
||||
flow._save_results_db, flow.claim_form.clear_screen) = orig
|
||||
text = out.getvalue()
|
||||
assert "[RESULT] claim 1/2 1: Pass" in text, text
|
||||
assert "[RESULT] claim 2/2 3: Not found" in text, text
|
||||
# The whole queue up front (2026-10-15): the page's Claim status panel
|
||||
# lists the claims still waiting, which no other line names.
|
||||
assert "[CLAIMS] Queue: 1, 3" in text, text
|
||||
assert text.index("[CLAIMS] Queue:") < text.index("--- claim 1/2"), text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = 0
|
||||
orig = cfg.LOGS
|
||||
for fn in CASES:
|
||||
name = fn.__name__.replace("_", " ")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg.LOGS = pathlib.Path(tmp) / "Logs"
|
||||
try:
|
||||
fn()
|
||||
print(f" ok {name}")
|
||||
except AssertionError as exc:
|
||||
print(f" FAIL {name}: {exc}")
|
||||
failures += 1
|
||||
except Exception as exc:
|
||||
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
failures += 1
|
||||
finally:
|
||||
cfg.LOGS = orig
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user